From 22b115fa9ad5f32f2a42daac024269743786ee91 Mon Sep 17 00:00:00 2001 From: clinique Date: Thu, 12 May 2022 18:10:28 +0200 Subject: [PATCH 1/7] Starting switch to Code Granting process Signed-off-by: clinique --- .../internal/NetatmoHandlerFactory.java | 20 +- .../internal/api/AuthenticationApi.java | 74 +++-- .../internal/api/NetatmoException.java | 3 +- .../netatmo/internal/api/SecurityApi.java | 6 +- .../internal/api/data/NetatmoConstants.java | 7 +- .../config/ApiHandlerConfiguration.java | 53 ++-- .../internal/config/ConfigurationLevel.java | 35 +++ .../internal/handler/ApiBridgeHandler.java | 106 ++++--- .../handler/capability/EventCapability.java | 4 +- .../servlet/AuthenticationServlet.java | 262 ++++++++++++++++++ .../internal/servlet/NetatmoServlet.java | 5 + .../internal/servlet/ServletService.java | 117 ++++++++ .../WebhookServlet.java} | 106 ++++--- .../main/resources/OH-INF/config/config.xml | 21 +- .../resources/OH-INF/i18n/netatmo.properties | 5 +- .../src/main/resources/templates/account.html | 4 + .../src/main/resources/templates/index.html | 68 +++++ 17 files changed, 726 insertions(+), 170 deletions(-) create mode 100644 bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java create mode 100644 bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/AuthenticationServlet.java create mode 100644 bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/NetatmoServlet.java create mode 100644 bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/ServletService.java rename bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/{webhook/NetatmoServlet.java => servlet/WebhookServlet.java} (53%) create mode 100644 bundles/org.openhab.binding.netatmo/src/main/resources/templates/account.html create mode 100644 bundles/org.openhab.binding.netatmo/src/main/resources/templates/index.html diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/NetatmoHandlerFactory.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/NetatmoHandlerFactory.java index 0ab898bbc95d9..91a16ee6571ce 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/NetatmoHandlerFactory.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/NetatmoHandlerFactory.java @@ -40,6 +40,7 @@ import org.openhab.binding.netatmo.internal.handler.capability.WeatherCapability; import org.openhab.binding.netatmo.internal.handler.channelhelper.ChannelHelper; import org.openhab.binding.netatmo.internal.providers.NetatmoDescriptionProvider; +import org.openhab.binding.netatmo.internal.servlet.ServletService; import org.openhab.core.config.core.ConfigParser; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; @@ -53,7 +54,6 @@ import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Modified; import org.osgi.service.component.annotations.Reference; -import org.osgi.service.http.HttpService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -71,17 +71,17 @@ public class NetatmoHandlerFactory extends BaseThingHandlerFactory { private final NetatmoDescriptionProvider stateDescriptionProvider; private final HttpClient httpClient; private final NADeserializer deserializer; - private final HttpService httpService; private final BindingConfiguration configuration = new BindingConfiguration(); + private final ServletService servletService; @Activate public NetatmoHandlerFactory(@Reference NetatmoDescriptionProvider stateDescriptionProvider, @Reference HttpClientFactory factory, @Reference NADeserializer deserializer, - @Reference HttpService httpService, Map config) { + @Reference ServletService servletService, Map config) { this.stateDescriptionProvider = stateDescriptionProvider; this.httpClient = factory.getCommonHttpClient(); - this.httpService = httpService; this.deserializer = deserializer; + this.servletService = servletService; configChanged(config); } @@ -107,7 +107,10 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { private BaseThingHandler buildHandler(Thing thing, ModuleType moduleType) { if (ModuleType.ACCOUNT.equals(moduleType)) { - return new ApiBridgeHandler((Bridge) thing, httpClient, httpService, deserializer, configuration); + ApiBridgeHandler bridgeHandler = new ApiBridgeHandler((Bridge) thing, httpClient, servletService, + deserializer, configuration); + servletService.addAccountHandler(bridgeHandler); + return bridgeHandler; } CommonInterface handler = moduleType.isABridge() ? new DeviceHandler((Bridge) thing) : new ModuleHandler(thing); @@ -154,4 +157,11 @@ private BaseThingHandler buildHandler(Thing thing, ModuleType moduleType) { return (BaseThingHandler) handler; } + + @Override + protected synchronized void removeHandler(ThingHandler thingHandler) { + if (thingHandler instanceof ApiBridgeHandler) { + servletService.removeAccountHandler((ApiBridgeHandler) thingHandler); + } + } } diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/AuthenticationApi.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/AuthenticationApi.java index 2da5144aef2c8..7126bbaf56dac 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/AuthenticationApi.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/AuthenticationApi.java @@ -12,7 +12,7 @@ */ package org.openhab.binding.netatmo.internal.api; -import static org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.PATH_OAUTH; +import static org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.*; import static org.openhab.core.auth.oauth2client.internal.Keyword.*; import java.net.URI; @@ -24,12 +24,14 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import javax.ws.rs.core.UriBuilder; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.FeatureArea; import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.Scope; import org.openhab.binding.netatmo.internal.api.dto.AccessTokenResponse; -import org.openhab.binding.netatmo.internal.config.ApiHandlerConfiguration.Credentials; +import org.openhab.binding.netatmo.internal.config.ApiHandlerConfiguration; import org.openhab.binding.netatmo.internal.handler.ApiBridgeHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,41 +43,58 @@ */ @NonNullByDefault public class AuthenticationApi extends RestManager { - private static final URI OAUTH_URI = getApiBaseBuilder().path(PATH_OAUTH).build(); + private static final UriBuilder OAUTH_BUILDER = getApiBaseBuilder().path(PATH_OAUTH); + private static final UriBuilder AUTH_BUILDER = OAUTH_BUILDER.clone().path(SUB_PATH_AUTHORIZE); + private static final URI TOKEN_URI = OAUTH_BUILDER.clone().path(SUB_PATH_TOKEN).build(); - private final ScheduledExecutorService scheduler; private final Logger logger = LoggerFactory.getLogger(AuthenticationApi.class); + private final ScheduledExecutorService scheduler; - private @Nullable ScheduledFuture refreshTokenJob; + private Optional> refreshTokenJob = Optional.empty(); private Optional tokenResponse = Optional.empty(); - private String scope = ""; public AuthenticationApi(ApiBridgeHandler bridge, ScheduledExecutorService scheduler) { super(bridge, FeatureArea.NONE); this.scheduler = scheduler; } - public void authenticate(Credentials credentials, Set features) throws NetatmoException { - Set requestedFeatures = !features.isEmpty() ? features : FeatureArea.AS_SET; - scope = FeatureArea.toScopeString(requestedFeatures); - requestToken(credentials.clientId, credentials.clientSecret, - Map.of(USERNAME, credentials.username, PASSWORD, credentials.password, SCOPE, scope)); + public String authorize(ApiHandlerConfiguration credentials, Set features) throws NetatmoException { + String clientId = credentials.clientId; + String clientSecret = credentials.clientSecret; + if (!(clientId.isBlank() || clientSecret.isBlank())) { + Map params = new HashMap<>(Map.of(SCOPE, toScopeString(features))); + String refreshToken = credentials.refreshToken; + if (!refreshToken.isBlank()) { + params.put(REFRESH_TOKEN, refreshToken); + } else { + String code = credentials.code; + String redirectUri = credentials.redirectUri; + if (!(code.isBlank() || redirectUri.isBlank())) { + params.putAll(Map.of(REDIRECT_URI, redirectUri, CODE, code)); + } + } + if (params.size() > 1) { + return requestToken(clientId, clientSecret, params); + } + } + throw new IllegalArgumentException("Inconsistent configuration state, please file a bug report."); } - private void requestToken(String id, String secret, Map entries) throws NetatmoException { + private String requestToken(String id, String secret, Map entries) throws NetatmoException { Map payload = new HashMap<>(entries); - payload.putAll(Map.of(GRANT_TYPE, entries.keySet().contains(PASSWORD) ? PASSWORD : REFRESH_TOKEN, CLIENT_ID, id, - CLIENT_SECRET, secret)); + payload.put(GRANT_TYPE, payload.keySet().contains(CODE) ? AUTHORIZATION_CODE : REFRESH_TOKEN); + payload.putAll(Map.of(CLIENT_ID, id, CLIENT_SECRET, secret)); disconnect(); - AccessTokenResponse response = post(OAUTH_URI, AccessTokenResponse.class, payload); - refreshTokenJob = scheduler.schedule(() -> { + AccessTokenResponse response = post(TOKEN_URI, AccessTokenResponse.class, payload); + refreshTokenJob = Optional.of(scheduler.schedule(() -> { try { requestToken(id, secret, Map.of(REFRESH_TOKEN, response.getRefreshToken())); } catch (NetatmoException e) { logger.warn("Unable to refresh access token : {}", e.getMessage()); } - }, Math.round(response.getExpiresIn() * 0.8), TimeUnit.SECONDS); + }, Math.round(response.getExpiresIn() * 0.8), TimeUnit.SECONDS)); tokenResponse = Optional.of(response); + return response.getRefreshToken(); } public void disconnect() { @@ -83,11 +102,8 @@ public void disconnect() { } public void dispose() { - ScheduledFuture job = refreshTokenJob; - if (job != null) { - job.cancel(true); - } - refreshTokenJob = null; + refreshTokenJob.ifPresent(job -> job.cancel(true)); + refreshTokenJob = Optional.empty(); } public @Nullable String getAuthorization() { @@ -95,12 +111,20 @@ public void dispose() { } public boolean matchesScopes(Set requiredScopes) { - // either we do not require any scope, either connected and all scopes available - return requiredScopes.isEmpty() + return requiredScopes.isEmpty() // either we do not require any scope, either connected and all scopes available || (isConnected() && tokenResponse.map(at -> at.getScope().containsAll(requiredScopes)).orElse(false)); } public boolean isConnected() { - return !tokenResponse.isEmpty(); + return tokenResponse.isPresent(); + } + + private static String toScopeString(Set features) { + return FeatureArea.toScopeString(features.isEmpty() ? FeatureArea.AS_SET : features); + } + + public static String getAuthorizationUrl(String clientId, String state, Set features) { + return AUTH_BUILDER.clone().queryParam(CLIENT_ID, clientId).queryParam(SCOPE, toScopeString(features)) + .queryParam(STATE, state).build().toString(); } } diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/NetatmoException.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/NetatmoException.java index 709f205e80828..79564cb62fcea 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/NetatmoException.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/NetatmoException.java @@ -53,6 +53,7 @@ public ServiceError getStatusCode() { public @Nullable String getMessage() { String message = super.getMessage(); return message == null ? null - : String.format("Rest call failed: statusCode=%s, message=%s", statusCode, message); + : ServiceError.UNKNOWN.equals(statusCode) ? message + : String.format("Rest call failed: statusCode=%s, message=%s", statusCode, message); } } diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/SecurityApi.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/SecurityApi.java index 3640144ff4902..1528077022f2e 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/SecurityApi.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/SecurityApi.java @@ -45,9 +45,10 @@ public SecurityApi(ApiBridgeHandler apiClient) { * * @throws NetatmoException If fail to call the API, e.g. server error or deserializing */ - public void dropWebhook() throws NetatmoException { + public boolean dropWebhook() throws NetatmoException { UriBuilder uriBuilder = getApiUriBuilder(SUB_PATH_DROPWEBHOOK); post(uriBuilder, ApiResponse.Ok.class, null, null); + return true; } /** @@ -56,9 +57,10 @@ public void dropWebhook() throws NetatmoException { * @param uri Your webhook callback url (required) * @throws NetatmoException If fail to call the API, e.g. server error or deserializing */ - public void addwebhook(URI uri) throws NetatmoException { + public boolean addwebhook(URI uri) throws NetatmoException { UriBuilder uriBuilder = getApiUriBuilder(SUB_PATH_ADDWEBHOOK, PARAM_URL, uri.toString()); post(uriBuilder, ApiResponse.Ok.class, null, null); + return true; } public Collection getPersonEvents(String homeId, String personId) throws NetatmoException { diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/data/NetatmoConstants.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/data/NetatmoConstants.java index 65829a0e18c88..bca1265c0dd42 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/data/NetatmoConstants.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/data/NetatmoConstants.java @@ -116,7 +116,9 @@ public enum MeasureClass { // Netatmo API urls public static final String URL_API = "https://api.netatmo.com/"; public static final String URL_APP = "https://app.netatmo.net/"; - public static final String PATH_OAUTH = "oauth2/token"; + public static final String PATH_OAUTH = "oauth2"; + public static final String SUB_PATH_TOKEN = "token"; + public static final String SUB_PATH_AUTHORIZE = "authorize"; public static final String PATH_API = "api"; public static final String PATH_COMMAND = "command"; public static final String SUB_PATH_PERSON_AWAY = "setpersonsaway"; @@ -148,6 +150,9 @@ public enum MeasureClass { public static final String PARAM_FAVORITES = "get_favorites"; public static final String PARAM_STATUS = "status"; + // Autentication process params + public static final String PARAM_ERROR = "error"; + // Global variables public static final int THERM_MAX_SETPOINT = 30; diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ApiHandlerConfiguration.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ApiHandlerConfiguration.java index 782d04c06879c..fe6a813e8191d 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ApiHandlerConfiguration.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ApiHandlerConfiguration.java @@ -13,8 +13,6 @@ package org.openhab.binding.netatmo.internal.config; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.netatmo.internal.api.NetatmoException; /** * The {@link ApiHandlerConfiguration} is responsible for holding configuration @@ -24,39 +22,28 @@ */ @NonNullByDefault public class ApiHandlerConfiguration { - public class Credentials { - public final String clientId, clientSecret, username, password; + public static final String CODE = "code"; + public static final String REDIRECT_URI = "redirectUri"; + public static final String REFRESH_TOKEN = "refreshToken"; - private Credentials(@Nullable String clientId, @Nullable String clientSecret, @Nullable String username, - @Nullable String password) throws NetatmoException { - this.clientSecret = checkMandatory(clientSecret, "@text/conf-error-no-client-secret"); - this.username = checkMandatory(username, "@text/conf-error-no-username"); - this.password = checkMandatory(password, "@text/conf-error-no-password"); - this.clientId = checkMandatory(clientId, "@text/conf-error-no-client-id"); - } - - private String checkMandatory(@Nullable String value, String error) throws NetatmoException { - if (value == null || value.isBlank()) { - throw new NetatmoException(error); - } - return value; - } - - @Override - public String toString() { - return "Credentials [clientId=" + clientId + ", username=" + username - + ", password=******, clientSecret=******]"; - } - } - - private @Nullable String clientId; - private @Nullable String clientSecret; - private @Nullable String username; - private @Nullable String password; - public @Nullable String webHookUrl; + public String clientId = ""; + public String clientSecret = ""; + public String code = ""; + public String redirectUri = ""; + public String refreshToken = ""; + public String webHookUrl = ""; public int reconnectInterval = 300; - public Credentials getCredentials() throws NetatmoException { - return new Credentials(clientId, clientSecret, username, password); + public ConfigurationLevel check() { + if (clientId.isBlank()) { + return ConfigurationLevel.EMPTY_CLIENT_ID; + } else if (clientSecret.isBlank()) { + return ConfigurationLevel.EMPTY_CLIENT_SECRET; + } else if (!refreshToken.isBlank()) { + return ConfigurationLevel.FINISHED; + } else if (!(redirectUri.isBlank() || code.isBlank())) { + return ConfigurationLevel.TOKEN_REFRESH_NEEDED; + } + return ConfigurationLevel.PENDING_GRANT; } } diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java new file mode 100644 index 0000000000000..bd42aef3795ae --- /dev/null +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2010-2022 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.netatmo.internal.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link ConfigurationLevel} describe configuration levels of a given account thing + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public enum ConfigurationLevel { + EMPTY_CLIENT_ID("@text/conf-error-no-client-secret"), + EMPTY_CLIENT_SECRET("@text/conf-error-no-client-secret"), + PENDING_GRANT("@text/conf-error-grant-needed"), + FINISHED(""), + TOKEN_REFRESH_NEEDED(""); + + public String message; + + ConfigurationLevel(String message) { + this.message = message; + } +} diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java index e0d7fe5c1a6d3..31575c6eb1582 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java @@ -44,11 +44,13 @@ import org.openhab.binding.netatmo.internal.api.RestManager; import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.Scope; import org.openhab.binding.netatmo.internal.config.ApiHandlerConfiguration; -import org.openhab.binding.netatmo.internal.config.ApiHandlerConfiguration.Credentials; import org.openhab.binding.netatmo.internal.config.BindingConfiguration; +import org.openhab.binding.netatmo.internal.config.ConfigurationLevel; import org.openhab.binding.netatmo.internal.deserialization.NADeserializer; import org.openhab.binding.netatmo.internal.discovery.NetatmoDiscoveryService; -import org.openhab.binding.netatmo.internal.webhook.NetatmoServlet; +import org.openhab.binding.netatmo.internal.servlet.ServletService; +import org.openhab.binding.netatmo.internal.servlet.WebhookServlet; +import org.openhab.core.config.core.Configuration; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; @@ -57,7 +59,6 @@ import org.openhab.core.thing.binding.BaseBridgeHandler; import org.openhab.core.thing.binding.ThingHandlerService; import org.openhab.core.types.Command; -import org.osgi.service.http.HttpService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -73,22 +74,20 @@ public class ApiBridgeHandler extends BaseBridgeHandler { private final Logger logger = LoggerFactory.getLogger(ApiBridgeHandler.class); private final BindingConfiguration bindingConf; - private final HttpService httpService; + private final ServletService servletService; private final AuthenticationApi connectApi; private final HttpClient httpClient; private final NADeserializer deserializer; private Optional> connectJob = Optional.empty(); - private Optional servlet = Optional.empty(); - private @NonNullByDefault({}) ApiHandlerConfiguration thingConf; - private Map, RestManager> managers = new HashMap<>(); + private Optional webhook = Optional.empty(); - public ApiBridgeHandler(Bridge bridge, HttpClient httpClient, HttpService httpService, NADeserializer deserializer, - BindingConfiguration configuration) { + public ApiBridgeHandler(Bridge bridge, HttpClient httpClient, ServletService servletService, + NADeserializer deserializer, BindingConfiguration configuration) { super(bridge); this.bindingConf = configuration; - this.httpService = httpService; + this.servletService = servletService; this.connectApi = new AuthenticationApi(this, scheduler); this.httpClient = httpClient; this.deserializer = deserializer; @@ -97,40 +96,49 @@ public ApiBridgeHandler(Bridge bridge, HttpClient httpClient, HttpService httpSe @Override public void initialize() { logger.debug("Initializing Netatmo API bridge handler."); - thingConf = getConfigAs(ApiHandlerConfiguration.class); updateStatus(ThingStatus.UNKNOWN); - scheduler.execute(() -> { - openConnection(); - String webHookUrl = thingConf.webHookUrl; - if (webHookUrl != null && !webHookUrl.isBlank()) { - servlet = Optional.of(new NetatmoServlet(httpService, this, webHookUrl)); - } - }); + scheduler.execute(() -> openConnection()); } private void openConnection() { - try { - Credentials credentials = thingConf.getCredentials(); - logger.debug("Connecting to Netatmo API."); - try { - connectApi.authenticate(credentials, bindingConf.features); - updateStatus(ThingStatus.ONLINE); - getThing().getThings().stream().filter(Thing::isEnabled).map(Thing::getHandler).filter(Objects::nonNull) - .map(CommonInterface.class::cast).forEach(CommonInterface::expireData); - } catch (NetatmoException e) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); - prepareReconnection(); - } - } catch (NetatmoException e) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage()); + ApiHandlerConfiguration credentials = getConfigAs(ApiHandlerConfiguration.class); + ConfigurationLevel level = credentials.check(); + switch (level) { + case EMPTY_CLIENT_ID: + case EMPTY_CLIENT_SECRET: + case PENDING_GRANT: + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, level.message); + break; + case TOKEN_REFRESH_NEEDED: + case FINISHED: + try { + logger.debug("Connecting to Netatmo API."); + String refreshToken = connectApi.authorize(credentials, bindingConf.features); + Configuration thingConfig = editConfiguration(); + thingConfig.put(ApiHandlerConfiguration.REFRESH_TOKEN, refreshToken); + updateConfiguration(thingConfig); + updateStatus(ThingStatus.ONLINE); + getThing().getThings().stream().filter(Thing::isEnabled).map(Thing::getHandler) + .filter(Objects::nonNull).map(CommonInterface.class::cast) + .forEach(CommonInterface::expireData); + + if (!credentials.webHookUrl.isBlank()) { + webhook = Optional.of(servletService.createWebhookServlet(this, credentials.clientId, + credentials.webHookUrl)); + } + } catch (NetatmoException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + prepareReconnection(); + } + break; } } private void prepareReconnection() { connectApi.disconnect(); freeConnectJob(); - connectJob = Optional - .of(scheduler.schedule(() -> openConnection(), thingConf.reconnectInterval, TimeUnit.SECONDS)); + connectJob = Optional.of(scheduler.schedule(() -> openConnection(), + getConfigAs(ApiHandlerConfiguration.class).reconnectInterval, TimeUnit.SECONDS)); } private void freeConnectJob() { @@ -141,8 +149,6 @@ private void freeConnectJob() { @Override public void dispose() { logger.debug("Shutting down Netatmo API bridge handler."); - servlet.ifPresent(servlet -> servlet.dispose()); - servlet = Optional.empty(); connectApi.dispose(); freeConnectJob(); super.dispose(); @@ -227,8 +233,8 @@ public BindingConfiguration getConfiguration() { return bindingConf; } - public Optional getServlet() { - return servlet; + public Optional getServlet() { + return webhook; } public NADeserializer getDeserializer() { @@ -238,4 +244,28 @@ public NADeserializer getDeserializer() { public boolean isConnected() { return connectApi.isConnected(); } + + public String getUIDString() { + return thing.getUID().toString(); + } + + public String getLabel() { + String label = thing.getLabel(); + return label == null ? "" : label.toString(); + } + + public void receiveAuthorization(String redirectUri, String code) { + Configuration thingConfig = editConfiguration(); + thingConfig.put(ApiHandlerConfiguration.CODE, code); + thingConfig.put(ApiHandlerConfiguration.REDIRECT_URI, redirectUri); + thingConfig.put(ApiHandlerConfiguration.REFRESH_TOKEN, ""); + updateConfiguration(thingConfig); + logger.info("Thing configuration updated - going to open connection to get refresh token."); + openConnection(); + } + + public String formatAuthorizationUrl() { + return AuthenticationApi.getAuthorizationUrl(getConfigAs(ApiHandlerConfiguration.class).clientId, + getUIDString(), bindingConf.features); + } } diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/capability/EventCapability.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/capability/EventCapability.java index a72abc4066508..2416e114359de 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/capability/EventCapability.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/capability/EventCapability.java @@ -17,7 +17,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.netatmo.internal.handler.ApiBridgeHandler; import org.openhab.binding.netatmo.internal.handler.CommonInterface; -import org.openhab.binding.netatmo.internal.webhook.NetatmoServlet; +import org.openhab.binding.netatmo.internal.servlet.WebhookServlet; /** * {@link EventCapability} is the base class for handlers @@ -29,7 +29,7 @@ */ @NonNullByDefault public class EventCapability extends Capability { - private Optional servlet = Optional.empty(); + private Optional servlet = Optional.empty(); public EventCapability(CommonInterface handler) { super(handler); diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/AuthenticationServlet.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/AuthenticationServlet.java new file mode 100644 index 0000000000000..92e9d6c633b32 --- /dev/null +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/AuthenticationServlet.java @@ -0,0 +1,262 @@ +/** + * Copyright (c) 2010-2022 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.netatmo.internal.servlet; + +import static org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.PARAM_ERROR; +import static org.openhab.core.auth.oauth2client.internal.Keyword.*; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.util.MultiMap; +import org.eclipse.jetty.util.UrlEncoded; +import org.openhab.binding.netatmo.internal.api.NetatmoException; +import org.openhab.binding.netatmo.internal.handler.ApiBridgeHandler; +import org.osgi.framework.BundleContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AuthenticationServlet} manages the authorization with the Netatmo API. The servlet implements the + * Authorization Code flow and saves the resulting refreshToken with the bridge. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class AuthenticationServlet extends HttpServlet implements NetatmoServlet { + private static final long serialVersionUID = 4817341543768441689L; + + private static final String TEMPLATE_PATH = "templates/"; + private static final String TEMPLATE_ACCOUNT = TEMPLATE_PATH + "account.html"; + private static final String TEMPLATE_INDEX = TEMPLATE_PATH + "index.html"; + + private static final String CONTENT_TYPE = "text/html;charset=UTF-8"; + + // Simple HTML templates for inserting messages. + private static final String HTML_EMPTY_ACCOUNTS = "

Manually add a Netatmo Bridge to authorize it here.

"; + private static final String HTML_ACCOUNT_AUTHORIZED = "

Authorized Netatmo Account bridges.

"; + private static final String HTML_ERROR = "

Call to Netatmo Connect failed with error: %s

"; + + private static final Pattern MESSAGE_KEY_PATTERN = Pattern.compile("\\$\\{([^\\}]+)\\}"); + + // Keys present in the index.html + private static final String KEY_PAGE_REFRESH = "pageRefresh"; + private static final String HTML_META_REFRESH_CONTENT = ""; + private static final String KEY_AUTHORIZED_ACCOUNT = "authorizedUser"; + private static final String KEY_ERROR = "error"; + private static final String KEY_ACCOUNTS = "accounts"; + private static final String KEY_REDIRECT_URI = "redirectUri"; + + // Keys present in the account.html + private static final String ACCOUNT_ID = "account.id"; + private static final String ACCOUNT_NAME = "account.name"; + private static final String ACCOUNT_AUTHORIZED_CLASS = "account.authorized"; + private static final String ACCOUNT_AUTHORIZE = "account.authorize"; + + private final Logger logger = LoggerFactory.getLogger(AuthenticationServlet.class); + private final ServletService servletService; + private final String indexTemplate; + private final String playerTemplate; + private final BundleContext bundleContext; + + public AuthenticationServlet(ServletService servletService, BundleContext bundleContext) { + this.servletService = servletService; + this.bundleContext = bundleContext; + try { + this.indexTemplate = readTemplate(TEMPLATE_INDEX); + this.playerTemplate = readTemplate(TEMPLATE_ACCOUNT); + } catch (IOException e) { + throw new IllegalArgumentException( + String.format("Error reading ressource files, please file a bug report : %s", e.getMessage())); + } + } + + /** + * 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 Netatmo Authentication servlet", templateName)); + } else { + try (InputStream inputStream = index.openStream()) { + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + } + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + logger.debug("Netatmo auth callback servlet received GET request {}.", req.getRequestURI()); + StringBuffer requestUrl = req.getRequestURL(); + if (requestUrl != null) { + final String servletBaseURL = requestUrl.toString(); + final Map replaceMap = new HashMap<>(); + + handleRedirect(replaceMap, servletBaseURL, req.getQueryString()); + resp.setContentType(CONTENT_TYPE); + replaceMap.put(KEY_REDIRECT_URI, servletBaseURL); + replaceMap.put(KEY_ACCOUNTS, formatPlayers(playerTemplate, servletBaseURL)); + resp.getWriter().append(replaceKeysFromMap(indexTemplate, replaceMap)); + resp.getWriter().close(); + } else { + logger.warn("Unexpected : requestUrl is null"); + } + } + + /** + * Handles a possible call from Netatmo to the redirect_uri. If that is the case it will pass the authorization + * codes via the url and these are processed. In case of an error this is shown to the user. If the user was + * authorized this is passed on to the handler. Based on all these different outcomes the HTML is generated to + * inform the user. + * + * @param replaceMap a map with key String values that will be mapped in the HTML templates. + * @param servletBaseURL the servlet base, which should be used as the Spotify redirect_uri value + * @param queryString the query part of the GET request this servlet is processing + */ + private void handleRedirect(Map replaceMap, String servletBaseURL, @Nullable String queryString) { + replaceMap.put(KEY_ERROR, ""); + replaceMap.put(KEY_PAGE_REFRESH, ""); + + if (queryString != null) { + final MultiMap<@Nullable String> params = new MultiMap<>(); + UrlEncoded.decodeTo(queryString, params, StandardCharsets.UTF_8.name()); + final String reqCode = params.getString(CODE); + final String reqState = params.getString(STATE); + final String reqError = params.getString(PARAM_ERROR); + + replaceMap.put(KEY_PAGE_REFRESH, + params.isEmpty() ? "" : String.format(HTML_META_REFRESH_CONTENT, servletBaseURL)); + if (reqError != null) { + logger.debug("Netatmo redirected with an error: {}", reqError); + replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, reqError)); + } else if (reqState != null && reqCode != null) { + try { + authorize(servletBaseURL, reqState, reqCode); + replaceMap.put(KEY_AUTHORIZED_ACCOUNT, HTML_ACCOUNT_AUTHORIZED); + } catch (NetatmoException e) { + logger.debug("Exception during authorizaton: ", e); + replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, e.getMessage())); + } + } + } + } + + /** + * Call with Netatmo 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 Netatmo redirect url + * @param state The Netatmo returned state value + * @param code The Netatmo returned code value + * @throws NetatmoException + */ + public void authorize(String servletBaseURL, String state, String code) throws NetatmoException { + ApiBridgeHandler listener = servletService.getAccountHandlers().get(state); + if (listener != null) { + listener.receiveAuthorization(servletBaseURL, code); + return; + } + throw new NetatmoException("Returned '%s' doesn't match any Bridge. Has it been removed?", state); + } + + /** + * Formats the HTML of all available Netatmo Account Bridges and returns it as a String + * + * @param playerTemplate The player template to format the player values in + * @param servletBaseURL the redirect_uri to be used in the authorization url created on the authorization button. + * @return A String with the players formatted with the player template + */ + private String formatPlayers(String accountTemplate, String servletBaseURL) { + final Collection handlers = servletService.getAccountHandlers().values(); + + return handlers.isEmpty() ? HTML_EMPTY_ACCOUNTS + : handlers.stream().map(p -> formatPlayer(accountTemplate, p, servletBaseURL)) + .collect(Collectors.joining()); + } + + /** + * Formats the HTML of a Netatmo Account Bridge and returns it as a String + * + * @param playerTemplate The player template to format the player values in + * @param handler The handler for the player to format + * @param servletBaseURL the redirect_uri to be used in the authorization url created on the authorization button. + * @return A String with the player formatted with the player template + */ + private String formatPlayer(String playerTemplate, ApiBridgeHandler handler, String servletBaseURL) { + final Map map = new HashMap<>(); + + map.put(ACCOUNT_ID, handler.getUIDString()); + map.put(ACCOUNT_NAME, handler.getLabel()); + + if (handler.isConnected()) { + map.put(ACCOUNT_AUTHORIZED_CLASS, " authorized"); + } else { + map.put(ACCOUNT_AUTHORIZED_CLASS, " Unauthorized"); + } + + map.put(ACCOUNT_AUTHORIZE, handler.formatAuthorizationUrl()); + return replaceKeysFromMap(playerTemplate, map); + } + + /** + * Replaces all keys from the map found in the template with values from the map. If the key is not found the key + * will be kept in the template. + * + * @param template template to replace keys with values + * @param map map with key value pairs to replace in the template + * @return a template with keys replaced + */ + private String replaceKeysFromMap(String template, Map map) { + final Matcher m = MESSAGE_KEY_PATTERN.matcher(template); + final StringBuffer sb = new StringBuffer(); + + while (m.find()) { + try { + final String key = m.group(1); + m.appendReplacement(sb, Matcher.quoteReplacement(map.getOrDefault(key, "${" + key + '}'))); + } catch (RuntimeException e) { + logger.debug("Error occurred during template filling, cause ", e); + } + } + m.appendTail(sb); + return sb.toString(); + } + + @Override + public String getPath() { + return "connect"; + } +} diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/NetatmoServlet.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/NetatmoServlet.java new file mode 100644 index 0000000000000..8a5c5c707c87a --- /dev/null +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/NetatmoServlet.java @@ -0,0 +1,5 @@ +package org.openhab.binding.netatmo.internal.servlet; + +public interface NetatmoServlet { + String getPath(); +} diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/ServletService.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/ServletService.java new file mode 100644 index 0000000000000..30b692f757ed5 --- /dev/null +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/ServletService.java @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2010-2022 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.netatmo.internal.servlet; + +import static org.openhab.binding.netatmo.internal.NetatmoBindingConstants.BINDING_ID; + +import java.util.HashMap; +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.openhab.binding.netatmo.internal.handler.ApiBridgeHandler; +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; + +/** + * The {@link ServletService} class to manage the servlets and bind authorization servlet to bridges. + * + * @author Gaël L'hopital - Initial contribution + */ +@Component(service = ServletService.class) +@NonNullByDefault +public class ServletService { + private static final String BASE_PATH = "/" + BINDING_ID + "/"; + + private final Logger logger = LoggerFactory.getLogger(ServletService.class); + private final Map accountHandlers = new HashMap<>(); + private final HttpService httpService; + private final BundleContext bundleContext; + private final Map servlets = new HashMap<>(); + + @Activate + public ServletService(@Reference HttpService httpService, ComponentContext componentContext) { + this.httpService = httpService; + this.bundleContext = componentContext.getBundleContext(); + createAuthenticationServlet(); + } + + private void createAuthenticationServlet() { + AuthenticationServlet authServlet = new AuthenticationServlet(this, bundleContext); + registerServlet(authServlet, authServlet.getPath()); + } + + public WebhookServlet createWebhookServlet(ApiBridgeHandler apiBridgeHandler, String clientId, String webHookUrl) { + WebhookServlet webhookServlet = new WebhookServlet(apiBridgeHandler, webHookUrl, clientId); + registerServlet(webhookServlet, webhookServlet.getPath()); + return webhookServlet; + } + + private void registerServlet(HttpServlet servlet, String servletPath) { + String path = BASE_PATH + servletPath; + try { + httpService.registerServlet(path, servlet, new Hashtable<>(), httpService.createDefaultHttpContext()); + servlets.put(servletPath, servlet); + logger.info("Registered Netatmo {} servlet at '{}'", servlet.getClass().getName(), servletPath); + } catch (NamespaceException | ServletException e) { + logger.warn("Error during Netatmo authentication servlet startup", e.getMessage()); + } + } + + @Deactivate + protected void deactivate(ComponentContext componentContext) { + servlets.keySet().forEach(alias -> { + httpService.unregister(alias); + }); + servlets.clear(); + } + + /** + * @param listener Adds the given handler + */ + public void addAccountHandler(ApiBridgeHandler listener) { + accountHandlers.put(listener.getUIDString(), listener); + } + + /** + * @param handler Removes the given handler + */ + public void removeAccountHandler(ApiBridgeHandler apiBridge) { + // TODO Ca ne marche pas mais l'idée est là... + servlets.forEach((alias, handler) -> { + if (handler.equals(apiBridge)) { + WebhookServlet webhook = (WebhookServlet) servlets.remove(alias); + webhook.dispose(); + } + }); + accountHandlers.remove(apiBridge.getUIDString()); + } + + /** + * @return Returns all {@link ApiBridgeHandler}s. + */ + public Map getAccountHandlers() { + return accountHandlers; + } +} diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/webhook/NetatmoServlet.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java similarity index 53% rename from bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/webhook/NetatmoServlet.java rename to bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java index ffd091afe8d87..cf82638fe5a92 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/webhook/NetatmoServlet.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.netatmo.internal.webhook; +package org.openhab.binding.netatmo.internal.servlet; import static org.openhab.binding.netatmo.internal.NetatmoBindingConstants.BINDING_ID; @@ -36,15 +36,12 @@ import javax.ws.rs.core.UriBuilderException; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.netatmo.internal.api.NetatmoException; import org.openhab.binding.netatmo.internal.api.SecurityApi; import org.openhab.binding.netatmo.internal.api.dto.WebhookEvent; import org.openhab.binding.netatmo.internal.deserialization.NADeserializer; import org.openhab.binding.netatmo.internal.handler.ApiBridgeHandler; import org.openhab.binding.netatmo.internal.handler.capability.EventCapability; -import org.osgi.service.http.HttpService; -import org.osgi.service.http.NamespaceException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -54,83 +51,79 @@ * @author Gaël L'hopital - Initial contribution */ @NonNullByDefault -public class NetatmoServlet extends HttpServlet { +public class WebhookServlet extends HttpServlet implements NetatmoServlet { private static final long serialVersionUID = -354583910860541214L; - private static final String CALLBACK_URI = "/" + BINDING_ID; - private final Logger logger = LoggerFactory.getLogger(NetatmoServlet.class); + private final Logger logger = LoggerFactory.getLogger(WebhookServlet.class); private final Map dataListeners = new ConcurrentHashMap<>(); - private final HttpService httpService; private final NADeserializer deserializer; private final Optional securityApi; + private final String clientId; + private boolean hookSet = false; - public NetatmoServlet(HttpService httpService, ApiBridgeHandler apiBridge, String webHookUrl) { - this.httpService = httpService; + public WebhookServlet(ApiBridgeHandler apiBridge, String webHookUrl, String clientId) { this.deserializer = apiBridge.getDeserializer(); + this.clientId = clientId; this.securityApi = Optional.ofNullable(apiBridge.getRestManager(SecurityApi.class)); securityApi.ifPresent(api -> { + URI uri = UriBuilder.fromUri(webHookUrl).path(BINDING_ID).path(clientId).build(); try { - httpService.registerServlet(CALLBACK_URI, this, null, httpService.createDefaultHttpContext()); - logger.debug("Started Netatmo Webhook Servlet at '{}'", CALLBACK_URI); - URI uri = UriBuilder.fromUri(webHookUrl).path(BINDING_ID).build(); - try { - logger.info("Setting Netatmo Welcome WebHook to {}", uri.toString()); - api.addwebhook(uri); - hookSet = true; - } catch (UriBuilderException e) { - logger.info("webhookUrl is not a valid URI '{}' : {}", uri, e.getMessage()); - } catch (NetatmoException e) { - logger.info("Error setting webhook : {}", e.getMessage()); - } - } catch (ServletException | NamespaceException e) { - logger.warn("Could not start Netatmo Webhook Servlet : {}", e.getMessage()); + logger.info("Setting Netatmo Welcome WebHook to {}", uri.toString()); + hookSet = api.addwebhook(uri); + } catch (UriBuilderException e) { + logger.info("webhookUrl is not a valid URI '{}' : {}", uri, e.getMessage()); + } catch (NetatmoException e) { + logger.info("Error setting webhook : {}", e.getMessage()); } }); } public void dispose() { - securityApi.ifPresent(api -> { - if (hookSet) { + if (hookSet) { + securityApi.ifPresent(api -> { logger.info("Releasing Netatmo Welcome WebHook"); try { - api.dropWebhook(); + hookSet = api.dropWebhook(); } catch (NetatmoException e) { logger.warn("Error releasing webhook : {}", e.getMessage()); } - } - httpService.unregister(CALLBACK_URI); - }); + // httpService.unregister(CALLBACK_URI); + }); + } logger.debug("Netatmo Webhook Servlet stopped"); } @Override - protected void service(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp) throws IOException { - if (req != null && resp != null) { - String data = inputStreamToString(req.getInputStream()); - if (!data.isEmpty()) { - logger.debug("Event transmitted from restService : {}", data); - try { - WebhookEvent event = deserializer.deserialize(WebhookEvent.class, data); - List tobeNotified = collectNotified(event); - dataListeners.keySet().stream().filter(tobeNotified::contains).forEach(id -> { - EventCapability module = dataListeners.get(id); - if (module != null) { - module.setNewData(event); - } - }); - } catch (NetatmoException e) { - logger.info("Error deserializing webhook data received : {}. {}", data, e.getMessage()); - } + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + logger.debug("Netatmo webhook callback servlet received GET request {}.", req.getRequestURI()); + } + + @Override + protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException { + String data = inputStreamToString(req.getInputStream()); + if (!data.isEmpty()) { + logger.debug("Event transmitted from restService : {}", data); + try { + WebhookEvent event = deserializer.deserialize(WebhookEvent.class, data); + List tobeNotified = collectNotified(event); + dataListeners.keySet().stream().filter(tobeNotified::contains).forEach(id -> { + EventCapability module = dataListeners.get(id); + if (module != null) { + module.setNewData(event); + } + }); + } catch (NetatmoException e) { + logger.info("Error deserializing webhook data received : {}. {}", data, e.getMessage()); } - resp.setCharacterEncoding(StandardCharsets.UTF_8.name()); - resp.setContentType(MediaType.APPLICATION_JSON); - resp.setHeader("Access-Control-Allow-Origin", "*"); - resp.setHeader("Access-Control-Allow-Methods", HttpMethod.POST); - resp.setIntHeader("Access-Control-Max-Age", 3600); - resp.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); - resp.getWriter().write(""); } + resp.setCharacterEncoding(StandardCharsets.UTF_8.name()); + resp.setContentType(MediaType.APPLICATION_JSON); + resp.setHeader("Access-Control-Allow-Origin", "*"); + resp.setHeader("Access-Control-Allow-Methods", HttpMethod.POST); + resp.setIntHeader("Access-Control-Max-Age", 3600); + resp.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); + resp.getWriter().write(""); } private List collectNotified(WebhookEvent event) { @@ -160,4 +153,9 @@ private String inputStreamToString(InputStream is) throws IOException { } return value; } + + @Override + public String getPath() { + return "webhook/" + clientId; + } } diff --git a/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/config/config.xml index e7023f6ce00d6..6fc5292f01154 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/config/config.xml +++ b/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/config/config.xml @@ -17,15 +17,24 @@ password - - - Your Netatmo API username (email). + + + Authentication code, provided by the oAuth2 authentication process. + password + true + + + + + Redirection Uri used by Netatmo to reach the openHab server. Provided during authentication process. + true - - - Your Netatmo API password. + + + Refresh token provided by the oAuth2 authentication process. password + true diff --git a/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/i18n/netatmo.properties b/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/i18n/netatmo.properties index cc52514f7d4e3..1da8f0382fb34 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/i18n/netatmo.properties +++ b/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/i18n/netatmo.properties @@ -181,7 +181,7 @@ channel-type.netatmo.floodlight-mode.description = State of the floodlight (On/O channel-type.netatmo.floodlight-mode.state.option.ON = On channel-type.netatmo.floodlight-mode.state.option.OFF = Off channel-type.netatmo.floodlight-mode.state.option.AUTO = Auto -channel-type.netatmo.gust-angle.label = Gust Angle +channel-type.netatmo.gust-angle.label = Gust AngleclientId channel-type.netatmo.gust-angle.description = Direction of the last 5 minutes highest gust wind channel-type.netatmo.gust-strength.label = Gust Strength channel-type.netatmo.gust-strength.description = Speed of the last 5 minutes highest gust wind @@ -330,8 +330,7 @@ thing-type.netatmo.wind.description = Wind sensor reporting wind angle and stren conf-error-no-client-id = Cannot connect to Netatmo bridge as no client id is available in the configuration conf-error-no-client-secret = Cannot connect to Netatmo bridge as no client secret is available in the configuration -conf-error-no-username = Cannot connect to Netatmo bridge as no username is available in the configuration -conf-error-no-password = Cannot connect to Netatmo bridge as no password is available in the configuration +conf-error-grant-needed = Configuration incomplete, please grant the binding to Netatmo Connect. status-bridge-offline = Bridge is not connected to Netatmo API device-not-connected = Thing is not reachable data-over-limit = Data seems quite old diff --git a/bundles/org.openhab.binding.netatmo/src/main/resources/templates/account.html b/bundles/org.openhab.binding.netatmo/src/main/resources/templates/account.html new file mode 100644 index 0000000000000..fce6b4c71d317 --- /dev/null +++ b/bundles/org.openhab.binding.netatmo/src/main/resources/templates/account.html @@ -0,0 +1,4 @@ +
+ Connect to Netatmo: ${account.name} +

+
diff --git a/bundles/org.openhab.binding.netatmo/src/main/resources/templates/index.html b/bundles/org.openhab.binding.netatmo/src/main/resources/templates/index.html new file mode 100644 index 0000000000000..de8cbc8a66086 --- /dev/null +++ b/bundles/org.openhab.binding.netatmo/src/main/resources/templates/index.html @@ -0,0 +1,68 @@ + + + + + +${pageRefresh} +Authorize openHAB binding for Netatmo + + + + +

Authorize openHAB binding for Netatmo

+

On this page you can authorize your openHAB Netatmo Bridge configured with the clientId and clientSecret of the Netatmo Application on your Developer account, you have to login to your Netatmo Account and authorize this binding to access your account.

+

To use this binding the following requirements apply:

+
    +
  • A Netatmo connect account. +
  • Register openHAB as an App on your Netatmo Connect account. +
+

+ The redirect URI to use with Netatmo for this openHAB installation is + ${redirectUri} +

+ ${error} ${accounts} + + From 73184143b35ea59ffbe459153a96498abd9792f0 Mon Sep 17 00:00:00 2001 From: clinique Date: Wed, 18 May 2022 15:58:38 +0200 Subject: [PATCH 2/7] Finalizing granting process Signed-off-by: clinique --- bundles/org.openhab.binding.netatmo/README.md | 46 ++- .../internal/NetatmoBindingConstants.java | 3 - .../internal/NetatmoHandlerFactory.java | 24 +- .../internal/api/AuthenticationApi.java | 11 +- .../netatmo/internal/api/SecurityApi.java | 3 +- .../config/ApiHandlerConfiguration.java | 13 +- .../internal/config/ConfigurationLevel.java | 5 +- .../internal/config/NAThingConfiguration.java | 2 + .../discovery/NetatmoDiscoveryService.java | 16 +- .../internal/handler/ApiBridgeHandler.java | 121 ++++---- .../internal/handler/CommonInterface.java | 2 +- .../handler/capability/EventCapability.java | 13 +- .../providers/NetatmoThingTypeProvider.java | 4 +- .../servlet/AuthenticationServlet.java | 262 ------------------ .../internal/servlet/GrantServlet.java | 152 ++++++++++ .../internal/servlet/NetatmoServlet.java | 62 ++++- .../internal/servlet/ServletService.java | 117 -------- .../internal/servlet/WebhookServlet.java | 119 ++++---- .../main/resources/OH-INF/config/config.xml | 15 +- .../resources/OH-INF/i18n/netatmo.properties | 2 +- .../src/main/resources/template/account.html | 74 +++++ .../src/main/resources/templates/account.html | 4 - .../src/main/resources/templates/index.html | 68 ----- 23 files changed, 475 insertions(+), 663 deletions(-) delete mode 100644 bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/AuthenticationServlet.java create mode 100644 bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/GrantServlet.java delete mode 100644 bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/ServletService.java create mode 100644 bundles/org.openhab.binding.netatmo/src/main/resources/template/account.html delete mode 100644 bundles/org.openhab.binding.netatmo/src/main/resources/templates/account.html delete mode 100644 bundles/org.openhab.binding.netatmo/src/main/resources/templates/index.html diff --git a/bundles/org.openhab.binding.netatmo/README.md b/bundles/org.openhab.binding.netatmo/README.md index 9dccea907597a..a8064dd214205 100644 --- a/bundles/org.openhab.binding.netatmo/README.md +++ b/bundles/org.openhab.binding.netatmo/README.md @@ -11,17 +11,20 @@ See https://www.netatmo.com/ for details on their product. ## Binding Configuration -Before setting up your 'Things', you will have to grant openHAB to access Netatmo API. -Here is the procedure: +The binding requires you to register an Application with Netatmo Connect at [https://dev.netatmo.com/](https://dev.netatmo.com/) - this will get you a set of Client ID and Client Secret parameters to be used by your configuration. -Create an application at https://dev.netatmo.com/dev/createapp +### Create Netatmo Application + +Follow instructions under: + + 1. Setting Up Your Account + 1. Registering Your Application + 1. Setting Redirect URI and webhook URI can be skipped, these will be provided by the binding. The variables you will need to get to setup the binding are: * `` Your client ID taken from your App at https://dev.netatmo.com/apps * `` A token provided along with the ``. -* `` The username you use to connect to the Netatmo API (usually your mail address). -* `` The password attached to the above username. The binding has the following configuration options: @@ -31,18 +34,31 @@ The binding has the following configuration options: | readFriends | Boolean | Enables or disables the discovery of guest weather stations. | -## Bridge Configuration +## Netatmo Account (Bridge) Configuration You will have to create at first a bridge to handle communication with your Netatmo Application. -The Account bridge has the following configuration options: +The Account bridge has the following configuration elements: + +| Parameter | Type | Required | Description | +|-------------------|--------|----------|----------------------------------------------------------------------------------------------------------------------------| +| clientId | String | Yes | Client ID provided for the application you created on http://dev.netatmo.com/createapp | +| clientSecret | String | Yes | Client Secret provided for the application you created | +| webHookUrl | String | No | Protocol, public IP and port to access openHAB server from Internet | +| reconnectInterval | Number | No | The reconnection interval to Netatmo API (in s) | +| refreshToken | String | No | The refresh token provided by Netatmo API after the granting process. Can be saved for in case of file based configuration | + +### Configure the Dridge + +1. Complete the Netatmo Application Registration if you have not already done so, see above. +1. Make sure you have your _Client ID_ and _Client Secret_ identities available. +1. Add a new **"Netatmo Account"** thing. Choose new Id for the account, unless you like the generated one, put in the _Client ID_ and _Client Secret_ from the Spotify Application registration in their respective fields of the bridge configuration. Save the bridge. +1. The bridge thing will go _OFFLINE_ / _CONFIGURATION_ERROR_ - this is fine. You have to authorize this bridge with Netatmo Connect. +1. Go to the authorization page of your server. `http://:8080/netatmo/connect/<_CLIENT_ID_>`. Your newly added bridge should be listed there. +1. Press the _"Authorize Thing"_ button. This will take you either to the login page of Netatmo Connect or directly to the authorization screen. Login and/or authorize the application. You will be returned and the entry should go green. +1. The binding will be updated with a refresh token and go _ONLINE_. The refresh token is used to re-authorize the bridge with Netatmo Connect Web API whenever required. -- **clientId:** Client ID provided for the application you created on http://dev.netatmo.com/createapp. -- **clientSecret:** Client Secret provided for the application you created. -- **username:** Your Netatmo API username (email). -- **password:** Your Netatmo API password. -- **webHookUrl:** Protocol, public IP and port to access openHAB server from Internet. -- **reconnectInterval:** The reconnection interval to Netatmo API (in s). +Now that you have got your bridge _ONLINE_ you can now start a scan with the binding to auto discover your things. ## List of supported things @@ -73,7 +89,7 @@ The Account bridge has the following configuration options: ### Webhook Netatmo servers can send push notifications to the Netatmo Binding by using a callback URL. -The webhook URL is setup at binding level using "Webhook Address" parameter. +The webhook URL is setup at Netatmo Account level using "Webhook Address" parameter. You will define here public way to access your openHAB server: ``` @@ -83,7 +99,7 @@ http(s)://xx.yy.zz.ww:443 Your Netatmo App will be configured automatically by the bridge to the endpoint: ``` -http(s)://xx.yy.zz.ww:443/netatmo +http(s)://xx.yy.zz.ww:443/netatmo/webhook/<_CLIENT_ID_> ``` Please be aware of Netatmo own limits regarding webhook usage that lead to a 24h ban-time when webhook does not answer 5 times. diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/NetatmoBindingConstants.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/NetatmoBindingConstants.java index 481cfc06d9104..fc556be85fae2 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/NetatmoBindingConstants.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/NetatmoBindingConstants.java @@ -27,9 +27,6 @@ public class NetatmoBindingConstants { public static final String BINDING_ID = "netatmo"; public static final String VENDOR = "Netatmo"; - // Configuration keys - public static final String EQUIPMENT_ID = "id"; - // Things properties public static final String PROPERTY_CITY = "city"; public static final String PROPERTY_COUNTRY = "country"; diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/NetatmoHandlerFactory.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/NetatmoHandlerFactory.java index 91a16ee6571ce..4bd697b859ecc 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/NetatmoHandlerFactory.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/NetatmoHandlerFactory.java @@ -40,7 +40,6 @@ import org.openhab.binding.netatmo.internal.handler.capability.WeatherCapability; import org.openhab.binding.netatmo.internal.handler.channelhelper.ChannelHelper; import org.openhab.binding.netatmo.internal.providers.NetatmoDescriptionProvider; -import org.openhab.binding.netatmo.internal.servlet.ServletService; import org.openhab.core.config.core.ConfigParser; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; @@ -54,6 +53,7 @@ import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Modified; import org.osgi.service.component.annotations.Reference; +import org.osgi.service.http.HttpService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -68,20 +68,20 @@ public class NetatmoHandlerFactory extends BaseThingHandlerFactory { private final Logger logger = LoggerFactory.getLogger(NetatmoHandlerFactory.class); + private final BindingConfiguration configuration = new BindingConfiguration(); private final NetatmoDescriptionProvider stateDescriptionProvider; - private final HttpClient httpClient; private final NADeserializer deserializer; - private final BindingConfiguration configuration = new BindingConfiguration(); - private final ServletService servletService; + private final HttpClient httpClient; + private final HttpService httpService; @Activate public NetatmoHandlerFactory(@Reference NetatmoDescriptionProvider stateDescriptionProvider, @Reference HttpClientFactory factory, @Reference NADeserializer deserializer, - @Reference ServletService servletService, Map config) { + @Reference HttpService httpService, Map config) { this.stateDescriptionProvider = stateDescriptionProvider; this.httpClient = factory.getCommonHttpClient(); this.deserializer = deserializer; - this.servletService = servletService; + this.httpService = httpService; configChanged(config); } @@ -107,10 +107,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { private BaseThingHandler buildHandler(Thing thing, ModuleType moduleType) { if (ModuleType.ACCOUNT.equals(moduleType)) { - ApiBridgeHandler bridgeHandler = new ApiBridgeHandler((Bridge) thing, httpClient, servletService, - deserializer, configuration); - servletService.addAccountHandler(bridgeHandler); - return bridgeHandler; + return new ApiBridgeHandler((Bridge) thing, httpClient, deserializer, configuration, httpService); } CommonInterface handler = moduleType.isABridge() ? new DeviceHandler((Bridge) thing) : new ModuleHandler(thing); @@ -157,11 +154,4 @@ private BaseThingHandler buildHandler(Thing thing, ModuleType moduleType) { return (BaseThingHandler) handler; } - - @Override - protected synchronized void removeHandler(ThingHandler thingHandler) { - if (thingHandler instanceof ApiBridgeHandler) { - servletService.removeAccountHandler((ApiBridgeHandler) thingHandler); - } - } } diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/AuthenticationApi.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/AuthenticationApi.java index 7126bbaf56dac..5aabc4194ec22 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/AuthenticationApi.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/AuthenticationApi.java @@ -58,7 +58,8 @@ public AuthenticationApi(ApiBridgeHandler bridge, ScheduledExecutorService sched this.scheduler = scheduler; } - public String authorize(ApiHandlerConfiguration credentials, Set features) throws NetatmoException { + public String authorize(ApiHandlerConfiguration credentials, Set features, @Nullable String code, + @Nullable String redirectUri) throws NetatmoException { String clientId = credentials.clientId; String clientSecret = credentials.clientSecret; if (!(clientId.isBlank() || clientSecret.isBlank())) { @@ -67,9 +68,7 @@ public String authorize(ApiHandlerConfiguration credentials, Set fe if (!refreshToken.isBlank()) { params.put(REFRESH_TOKEN, refreshToken); } else { - String code = credentials.code; - String redirectUri = credentials.redirectUri; - if (!(code.isBlank() || redirectUri.isBlank())) { + if (code != null && redirectUri != null) { params.putAll(Map.of(REDIRECT_URI, redirectUri, CODE, code)); } } @@ -123,8 +122,8 @@ private static String toScopeString(Set features) { return FeatureArea.toScopeString(features.isEmpty() ? FeatureArea.AS_SET : features); } - public static String getAuthorizationUrl(String clientId, String state, Set features) { + public static UriBuilder getAuthorizationBuilder(String clientId, Set features) { return AUTH_BUILDER.clone().queryParam(CLIENT_ID, clientId).queryParam(SCOPE, toScopeString(features)) - .queryParam(STATE, state).build().toString(); + .queryParam(STATE, clientId); } } diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/SecurityApi.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/SecurityApi.java index 1528077022f2e..f474aebd01a33 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/SecurityApi.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/SecurityApi.java @@ -45,10 +45,9 @@ public SecurityApi(ApiBridgeHandler apiClient) { * * @throws NetatmoException If fail to call the API, e.g. server error or deserializing */ - public boolean dropWebhook() throws NetatmoException { + public void dropWebhook() throws NetatmoException { UriBuilder uriBuilder = getApiUriBuilder(SUB_PATH_DROPWEBHOOK); post(uriBuilder, ApiResponse.Ok.class, null, null); - return true; } /** diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ApiHandlerConfiguration.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ApiHandlerConfiguration.java index fe6a813e8191d..b7a02105d5109 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ApiHandlerConfiguration.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ApiHandlerConfiguration.java @@ -22,14 +22,11 @@ */ @NonNullByDefault public class ApiHandlerConfiguration { - public static final String CODE = "code"; - public static final String REDIRECT_URI = "redirectUri"; + public static final String CLIENT_ID = "clientId"; public static final String REFRESH_TOKEN = "refreshToken"; public String clientId = ""; public String clientSecret = ""; - public String code = ""; - public String redirectUri = ""; public String refreshToken = ""; public String webHookUrl = ""; public int reconnectInterval = 300; @@ -39,11 +36,9 @@ public ConfigurationLevel check() { return ConfigurationLevel.EMPTY_CLIENT_ID; } else if (clientSecret.isBlank()) { return ConfigurationLevel.EMPTY_CLIENT_SECRET; - } else if (!refreshToken.isBlank()) { - return ConfigurationLevel.FINISHED; - } else if (!(redirectUri.isBlank() || code.isBlank())) { - return ConfigurationLevel.TOKEN_REFRESH_NEEDED; + } else if (refreshToken.isBlank()) { + return ConfigurationLevel.REFRESH_TOKEN_NEEDED; } - return ConfigurationLevel.PENDING_GRANT; + return ConfigurationLevel.COMPLETED; } } diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java index bd42aef3795ae..a7d29fbf11540 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java @@ -23,9 +23,8 @@ public enum ConfigurationLevel { EMPTY_CLIENT_ID("@text/conf-error-no-client-secret"), EMPTY_CLIENT_SECRET("@text/conf-error-no-client-secret"), - PENDING_GRANT("@text/conf-error-grant-needed"), - FINISHED(""), - TOKEN_REFRESH_NEEDED(""); + REFRESH_TOKEN_NEEDED("@text/conf-error-grant-needed"), + COMPLETED(""); public String message; diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/NAThingConfiguration.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/NAThingConfiguration.java index 621dac474faa5..7ed9f73dc21ce 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/NAThingConfiguration.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/NAThingConfiguration.java @@ -22,6 +22,8 @@ */ @NonNullByDefault public class NAThingConfiguration { + public static final String ID = "id"; + public String id = ""; public int refreshInterval = -1; } diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/discovery/NetatmoDiscoveryService.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/discovery/NetatmoDiscoveryService.java index e1ec6ff247ef4..523ff2d009e07 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/discovery/NetatmoDiscoveryService.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/discovery/NetatmoDiscoveryService.java @@ -12,8 +12,6 @@ */ package org.openhab.binding.netatmo.internal.discovery; -import static org.openhab.binding.netatmo.internal.NetatmoBindingConstants.EQUIPMENT_ID; - import java.util.Set; import java.util.stream.Collectors; @@ -28,7 +26,7 @@ import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.FeatureArea; import org.openhab.binding.netatmo.internal.api.dto.NAMain; import org.openhab.binding.netatmo.internal.api.dto.NAModule; -import org.openhab.binding.netatmo.internal.config.BindingConfiguration; +import org.openhab.binding.netatmo.internal.config.NAThingConfiguration; import org.openhab.binding.netatmo.internal.handler.ApiBridgeHandler; import org.openhab.core.config.discovery.AbstractDiscoveryService; import org.openhab.core.config.discovery.DiscoveryResultBuilder; @@ -52,7 +50,7 @@ public class NetatmoDiscoveryService extends AbstractDiscoveryService implements private static final int DISCOVER_TIMEOUT_SECONDS = 5; private final Logger logger = LoggerFactory.getLogger(NetatmoDiscoveryService.class); private @Nullable ApiBridgeHandler handler; - private @Nullable BindingConfiguration config; + private boolean readFriends; public NetatmoDiscoveryService() { super(ModuleType.AS_SET.stream().filter(mt -> !SKIPPED_TYPES.contains(mt)).map(mt -> mt.thingTypeUID) @@ -61,9 +59,8 @@ public NetatmoDiscoveryService() { @Override public void startScan() { - BindingConfiguration localConf = config; ApiBridgeHandler localHandler = handler; - if (localHandler != null && localConf != null) { + if (localHandler != null) { ThingUID apiBridgeUID = localHandler.getThing().getUID(); try { AircareApi airCareApi = localHandler.getRestManager(AircareApi.class); @@ -73,7 +70,7 @@ public void startScan() { body.getElements().stream().forEach(homeCoach -> createThing(homeCoach, apiBridgeUID)); } } - if (localConf.readFriends) { + if (readFriends) { WeatherApi weatherApi = localHandler.getRestManager(WeatherApi.class); if (weatherApi != null) { // Search favorite stations ListBodyResponse body = weatherApi.getStationsData(null, true).getBody(); @@ -127,7 +124,8 @@ private ThingUID findThingUID(ModuleType thingType, String thingId, @Nullable Th private ThingUID createThing(NAModule module, @Nullable ThingUID bridgeUID) { ThingUID moduleUID = findThingUID(module.getType(), module.getId(), bridgeUID); DiscoveryResultBuilder resultBuilder = DiscoveryResultBuilder.create(moduleUID) - .withProperty(EQUIPMENT_ID, module.getId()).withRepresentationProperty(EQUIPMENT_ID) + .withProperty(NAThingConfiguration.ID, module.getId()) + .withRepresentationProperty(NAThingConfiguration.ID) .withLabel(module.getName() != null ? module.getName() : module.getId()); if (bridgeUID != null) { resultBuilder.withBridge(bridgeUID); @@ -140,7 +138,7 @@ private ThingUID createThing(NAModule module, @Nullable ThingUID bridgeUID) { public void setThingHandler(ThingHandler handler) { if (handler instanceof ApiBridgeHandler) { this.handler = (ApiBridgeHandler) handler; - this.config = ((ApiBridgeHandler) handler).getConfiguration(); + this.readFriends = ((ApiBridgeHandler) handler).getReadFriends(); } } diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java index 31575c6eb1582..c186138cafdfa 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java @@ -28,6 +28,8 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import javax.ws.rs.core.UriBuilder; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; @@ -42,13 +44,14 @@ import org.openhab.binding.netatmo.internal.api.AuthenticationApi; import org.openhab.binding.netatmo.internal.api.NetatmoException; import org.openhab.binding.netatmo.internal.api.RestManager; +import org.openhab.binding.netatmo.internal.api.SecurityApi; import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.Scope; import org.openhab.binding.netatmo.internal.config.ApiHandlerConfiguration; import org.openhab.binding.netatmo.internal.config.BindingConfiguration; import org.openhab.binding.netatmo.internal.config.ConfigurationLevel; import org.openhab.binding.netatmo.internal.deserialization.NADeserializer; import org.openhab.binding.netatmo.internal.discovery.NetatmoDiscoveryService; -import org.openhab.binding.netatmo.internal.servlet.ServletService; +import org.openhab.binding.netatmo.internal.servlet.GrantServlet; import org.openhab.binding.netatmo.internal.servlet.WebhookServlet; import org.openhab.core.config.core.Configuration; import org.openhab.core.thing.Bridge; @@ -59,6 +62,7 @@ import org.openhab.core.thing.binding.BaseBridgeHandler; import org.openhab.core.thing.binding.ThingHandlerService; import org.openhab.core.types.Command; +import org.osgi.service.http.HttpService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -74,71 +78,92 @@ public class ApiBridgeHandler extends BaseBridgeHandler { private final Logger logger = LoggerFactory.getLogger(ApiBridgeHandler.class); private final BindingConfiguration bindingConf; - private final ServletService servletService; private final AuthenticationApi connectApi; private final HttpClient httpClient; private final NADeserializer deserializer; + private final HttpService httpService; private Optional> connectJob = Optional.empty(); private Map, RestManager> managers = new HashMap<>(); - private Optional webhook = Optional.empty(); + private @Nullable WebhookServlet webHookServlet; + private @Nullable GrantServlet grantServlet; - public ApiBridgeHandler(Bridge bridge, HttpClient httpClient, ServletService servletService, - NADeserializer deserializer, BindingConfiguration configuration) { + public ApiBridgeHandler(Bridge bridge, HttpClient httpClient, NADeserializer deserializer, + BindingConfiguration configuration, HttpService httpService) { super(bridge); this.bindingConf = configuration; - this.servletService = servletService; this.connectApi = new AuthenticationApi(this, scheduler); this.httpClient = httpClient; this.deserializer = deserializer; + this.httpService = httpService; } @Override public void initialize() { logger.debug("Initializing Netatmo API bridge handler."); updateStatus(ThingStatus.UNKNOWN); - scheduler.execute(() -> openConnection()); + scheduler.execute(() -> openConnection(null, null)); } - private void openConnection() { - ApiHandlerConfiguration credentials = getConfigAs(ApiHandlerConfiguration.class); - ConfigurationLevel level = credentials.check(); + public void openConnection(@Nullable String code, @Nullable String redirectUri) { + ApiHandlerConfiguration configuration = getConfiguration(); + ConfigurationLevel level = configuration.check(); switch (level) { case EMPTY_CLIENT_ID: case EMPTY_CLIENT_SECRET: - case PENDING_GRANT: updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, level.message); break; - case TOKEN_REFRESH_NEEDED: - case FINISHED: + case REFRESH_TOKEN_NEEDED: + if (code == null || redirectUri == null) { + GrantServlet servlet = new GrantServlet(this, httpService); + servlet.startListening(); + this.grantServlet = servlet; + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, level.message); + break; + } // else we can proceed to get the token refresh + case COMPLETED: try { logger.debug("Connecting to Netatmo API."); - String refreshToken = connectApi.authorize(credentials, bindingConf.features); + + String refreshToken = connectApi.authorize(configuration, bindingConf.features, code, redirectUri); + Configuration thingConfig = editConfiguration(); thingConfig.put(ApiHandlerConfiguration.REFRESH_TOKEN, refreshToken); updateConfiguration(thingConfig); + + if (!configuration.webHookUrl.isBlank()) { + SecurityApi securityApi = getRestManager(SecurityApi.class); + if (securityApi != null) { + WebhookServlet servlet = new WebhookServlet(this, httpService, deserializer, securityApi, + configuration.webHookUrl); + servlet.startListening(); + this.webHookServlet = servlet; + } + } + updateStatus(ThingStatus.ONLINE); + getThing().getThings().stream().filter(Thing::isEnabled).map(Thing::getHandler) .filter(Objects::nonNull).map(CommonInterface.class::cast) .forEach(CommonInterface::expireData); - if (!credentials.webHookUrl.isBlank()) { - webhook = Optional.of(servletService.createWebhookServlet(this, credentials.clientId, - credentials.webHookUrl)); - } } catch (NetatmoException e) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); - prepareReconnection(); + prepareReconnection(code, redirectUri); } break; } } - private void prepareReconnection() { + public ApiHandlerConfiguration getConfiguration() { + return getConfigAs(ApiHandlerConfiguration.class); + } + + private void prepareReconnection(@Nullable String code, @Nullable String redirectUri) { connectApi.disconnect(); freeConnectJob(); - connectJob = Optional.of(scheduler.schedule(() -> openConnection(), - getConfigAs(ApiHandlerConfiguration.class).reconnectInterval, TimeUnit.SECONDS)); + connectJob = Optional.of(scheduler.schedule(() -> openConnection(code, redirectUri), + getConfiguration().reconnectInterval, TimeUnit.SECONDS)); } private void freeConnectJob() { @@ -149,6 +174,14 @@ private void freeConnectJob() { @Override public void dispose() { logger.debug("Shutting down Netatmo API bridge handler."); + WebhookServlet localWebHook = this.webHookServlet; + if (localWebHook != null) { + localWebHook.dispose(); + } + GrantServlet localGrant = this.grantServlet; + if (localGrant != null) { + localGrant.dispose(); + } connectApi.dispose(); freeConnectJob(); super.dispose(); @@ -159,11 +192,6 @@ public void handleCommand(ChannelUID channelUID, Command command) { logger.debug("Netatmo Bridge is read-only and does not handle commands"); } - @Override - public Collection> getServices() { - return Set.of(NetatmoDiscoveryService.class); - } - @SuppressWarnings("unchecked") public @Nullable T getRestManager(Class clazz) { if (!managers.containsKey(clazz)) { @@ -224,48 +252,33 @@ public synchronized T executeUri(URI uri, HttpMethod method, Class clazz, return executeUri(uri, method, clazz, payload, contentType, retryCount - 1); } updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/request-time-out"); - prepareReconnection(); + prepareReconnection(null, null); throw new NetatmoException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage())); } } - public BindingConfiguration getConfiguration() { - return bindingConf; - } - - public Optional getServlet() { - return webhook; - } - - public NADeserializer getDeserializer() { - return deserializer; + public boolean getReadFriends() { + return bindingConf.readFriends; } public boolean isConnected() { return connectApi.isConnected(); } - public String getUIDString() { - return thing.getUID().toString(); + public String getId() { + return (String) getThing().getConfiguration().get(ApiHandlerConfiguration.CLIENT_ID); } - public String getLabel() { - String label = thing.getLabel(); - return label == null ? "" : label.toString(); + public UriBuilder formatAuthorizationUrl() { + return AuthenticationApi.getAuthorizationBuilder(getId(), bindingConf.features); } - public void receiveAuthorization(String redirectUri, String code) { - Configuration thingConfig = editConfiguration(); - thingConfig.put(ApiHandlerConfiguration.CODE, code); - thingConfig.put(ApiHandlerConfiguration.REDIRECT_URI, redirectUri); - thingConfig.put(ApiHandlerConfiguration.REFRESH_TOKEN, ""); - updateConfiguration(thingConfig); - logger.info("Thing configuration updated - going to open connection to get refresh token."); - openConnection(); + @Override + public Collection> getServices() { + return Set.of(NetatmoDiscoveryService.class); } - public String formatAuthorizationUrl() { - return AuthenticationApi.getAuthorizationUrl(getConfigAs(ApiHandlerConfiguration.class).clientId, - getUIDString(), bindingConf.features); + public Optional getWebHookServlet() { + return Optional.ofNullable(webHookServlet); } } diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/CommonInterface.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/CommonInterface.java index aa519e21bbee6..adec7694db92a 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/CommonInterface.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/CommonInterface.java @@ -106,7 +106,7 @@ default void expireData() { } default String getId() { - return (String) getThing().getConfiguration().get("id"); + return (String) getThing().getConfiguration().get(NAThingConfiguration.ID); } default Stream getActiveChannels() { diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/capability/EventCapability.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/capability/EventCapability.java index 2416e114359de..aa4e294981d17 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/capability/EventCapability.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/capability/EventCapability.java @@ -20,16 +20,15 @@ import org.openhab.binding.netatmo.internal.servlet.WebhookServlet; /** - * {@link EventCapability} is the base class for handlers - * subject to receive event notifications. This class registers to webhookservlet so - * it can be notified when an event arrives. + * {@link EventCapability} is the base class for handlers subject to receive event notifications. + * This class registers to NetatmoServletService so it can be notified when an event arrives. * * @author Gaël L'hopital - Initial contribution * */ @NonNullByDefault public class EventCapability extends Capability { - private Optional servlet = Optional.empty(); + private Optional webhook = Optional.empty(); public EventCapability(CommonInterface handler) { super(handler); @@ -39,13 +38,13 @@ public EventCapability(CommonInterface handler) { public void initialize() { ApiBridgeHandler accountHandler = handler.getAccountHandler(); if (accountHandler != null) { - servlet = accountHandler.getServlet(); - servlet.ifPresent(s -> s.registerDataListener(handler.getId(), this)); + webhook = accountHandler.getWebHookServlet(); + webhook.ifPresent(servlet -> servlet.registerDataListener(handler.getId(), this)); } } @Override public void dispose() { - servlet.ifPresent(s -> s.unregisterDataListener(this)); + webhook.ifPresent(servlet -> servlet.unregisterDataListener(handler.getId())); } } diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/providers/NetatmoThingTypeProvider.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/providers/NetatmoThingTypeProvider.java index 0e81eb687e32c..7f5608beb3c21 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/providers/NetatmoThingTypeProvider.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/providers/NetatmoThingTypeProvider.java @@ -23,6 +23,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.netatmo.internal.api.data.ModuleType; +import org.openhab.binding.netatmo.internal.config.NAThingConfiguration; import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.binding.ThingTypeProvider; import org.openhab.core.thing.i18n.ThingTypeI18nLocalizationService; @@ -73,7 +74,8 @@ public Collection getThingTypes(@Nullable Locale locale) { ModuleType moduleType = ModuleType.from(thingTypeUID); ThingTypeBuilder thingTypeBuilder = ThingTypeBuilder.instance(thingTypeUID, thingTypeUID.toString()) - .withRepresentationProperty(EQUIPMENT_ID).withExtensibleChannelTypeIds(moduleType.extensions) + .withRepresentationProperty(NAThingConfiguration.ID) + .withExtensibleChannelTypeIds(moduleType.extensions) .withChannelGroupDefinitions(getGroupDefinitions(moduleType)) .withConfigDescriptionURI(moduleType.getConfigDescription()); diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/AuthenticationServlet.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/AuthenticationServlet.java deleted file mode 100644 index 92e9d6c633b32..0000000000000 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/AuthenticationServlet.java +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Copyright (c) 2010-2022 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.netatmo.internal.servlet; - -import static org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.PARAM_ERROR; -import static org.openhab.core.auth.oauth2client.internal.Keyword.*; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -import javax.servlet.ServletException; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.eclipse.jetty.util.MultiMap; -import org.eclipse.jetty.util.UrlEncoded; -import org.openhab.binding.netatmo.internal.api.NetatmoException; -import org.openhab.binding.netatmo.internal.handler.ApiBridgeHandler; -import org.osgi.framework.BundleContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * The {@link AuthenticationServlet} manages the authorization with the Netatmo API. The servlet implements the - * Authorization Code flow and saves the resulting refreshToken with the bridge. - * - * @author Gaël L'hopital - Initial contribution - */ -@NonNullByDefault -public class AuthenticationServlet extends HttpServlet implements NetatmoServlet { - private static final long serialVersionUID = 4817341543768441689L; - - private static final String TEMPLATE_PATH = "templates/"; - private static final String TEMPLATE_ACCOUNT = TEMPLATE_PATH + "account.html"; - private static final String TEMPLATE_INDEX = TEMPLATE_PATH + "index.html"; - - private static final String CONTENT_TYPE = "text/html;charset=UTF-8"; - - // Simple HTML templates for inserting messages. - private static final String HTML_EMPTY_ACCOUNTS = "

Manually add a Netatmo Bridge to authorize it here.

"; - private static final String HTML_ACCOUNT_AUTHORIZED = "

Authorized Netatmo Account bridges.

"; - private static final String HTML_ERROR = "

Call to Netatmo Connect failed with error: %s

"; - - private static final Pattern MESSAGE_KEY_PATTERN = Pattern.compile("\\$\\{([^\\}]+)\\}"); - - // Keys present in the index.html - private static final String KEY_PAGE_REFRESH = "pageRefresh"; - private static final String HTML_META_REFRESH_CONTENT = ""; - private static final String KEY_AUTHORIZED_ACCOUNT = "authorizedUser"; - private static final String KEY_ERROR = "error"; - private static final String KEY_ACCOUNTS = "accounts"; - private static final String KEY_REDIRECT_URI = "redirectUri"; - - // Keys present in the account.html - private static final String ACCOUNT_ID = "account.id"; - private static final String ACCOUNT_NAME = "account.name"; - private static final String ACCOUNT_AUTHORIZED_CLASS = "account.authorized"; - private static final String ACCOUNT_AUTHORIZE = "account.authorize"; - - private final Logger logger = LoggerFactory.getLogger(AuthenticationServlet.class); - private final ServletService servletService; - private final String indexTemplate; - private final String playerTemplate; - private final BundleContext bundleContext; - - public AuthenticationServlet(ServletService servletService, BundleContext bundleContext) { - this.servletService = servletService; - this.bundleContext = bundleContext; - try { - this.indexTemplate = readTemplate(TEMPLATE_INDEX); - this.playerTemplate = readTemplate(TEMPLATE_ACCOUNT); - } catch (IOException e) { - throw new IllegalArgumentException( - String.format("Error reading ressource files, please file a bug report : %s", e.getMessage())); - } - } - - /** - * 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 Netatmo Authentication servlet", templateName)); - } else { - try (InputStream inputStream = index.openStream()) { - return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - } - } - } - - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - logger.debug("Netatmo auth callback servlet received GET request {}.", req.getRequestURI()); - StringBuffer requestUrl = req.getRequestURL(); - if (requestUrl != null) { - final String servletBaseURL = requestUrl.toString(); - final Map replaceMap = new HashMap<>(); - - handleRedirect(replaceMap, servletBaseURL, req.getQueryString()); - resp.setContentType(CONTENT_TYPE); - replaceMap.put(KEY_REDIRECT_URI, servletBaseURL); - replaceMap.put(KEY_ACCOUNTS, formatPlayers(playerTemplate, servletBaseURL)); - resp.getWriter().append(replaceKeysFromMap(indexTemplate, replaceMap)); - resp.getWriter().close(); - } else { - logger.warn("Unexpected : requestUrl is null"); - } - } - - /** - * Handles a possible call from Netatmo to the redirect_uri. If that is the case it will pass the authorization - * codes via the url and these are processed. In case of an error this is shown to the user. If the user was - * authorized this is passed on to the handler. Based on all these different outcomes the HTML is generated to - * inform the user. - * - * @param replaceMap a map with key String values that will be mapped in the HTML templates. - * @param servletBaseURL the servlet base, which should be used as the Spotify redirect_uri value - * @param queryString the query part of the GET request this servlet is processing - */ - private void handleRedirect(Map replaceMap, String servletBaseURL, @Nullable String queryString) { - replaceMap.put(KEY_ERROR, ""); - replaceMap.put(KEY_PAGE_REFRESH, ""); - - if (queryString != null) { - final MultiMap<@Nullable String> params = new MultiMap<>(); - UrlEncoded.decodeTo(queryString, params, StandardCharsets.UTF_8.name()); - final String reqCode = params.getString(CODE); - final String reqState = params.getString(STATE); - final String reqError = params.getString(PARAM_ERROR); - - replaceMap.put(KEY_PAGE_REFRESH, - params.isEmpty() ? "" : String.format(HTML_META_REFRESH_CONTENT, servletBaseURL)); - if (reqError != null) { - logger.debug("Netatmo redirected with an error: {}", reqError); - replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, reqError)); - } else if (reqState != null && reqCode != null) { - try { - authorize(servletBaseURL, reqState, reqCode); - replaceMap.put(KEY_AUTHORIZED_ACCOUNT, HTML_ACCOUNT_AUTHORIZED); - } catch (NetatmoException e) { - logger.debug("Exception during authorizaton: ", e); - replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, e.getMessage())); - } - } - } - } - - /** - * Call with Netatmo 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 Netatmo redirect url - * @param state The Netatmo returned state value - * @param code The Netatmo returned code value - * @throws NetatmoException - */ - public void authorize(String servletBaseURL, String state, String code) throws NetatmoException { - ApiBridgeHandler listener = servletService.getAccountHandlers().get(state); - if (listener != null) { - listener.receiveAuthorization(servletBaseURL, code); - return; - } - throw new NetatmoException("Returned '%s' doesn't match any Bridge. Has it been removed?", state); - } - - /** - * Formats the HTML of all available Netatmo Account Bridges and returns it as a String - * - * @param playerTemplate The player template to format the player values in - * @param servletBaseURL the redirect_uri to be used in the authorization url created on the authorization button. - * @return A String with the players formatted with the player template - */ - private String formatPlayers(String accountTemplate, String servletBaseURL) { - final Collection handlers = servletService.getAccountHandlers().values(); - - return handlers.isEmpty() ? HTML_EMPTY_ACCOUNTS - : handlers.stream().map(p -> formatPlayer(accountTemplate, p, servletBaseURL)) - .collect(Collectors.joining()); - } - - /** - * Formats the HTML of a Netatmo Account Bridge and returns it as a String - * - * @param playerTemplate The player template to format the player values in - * @param handler The handler for the player to format - * @param servletBaseURL the redirect_uri to be used in the authorization url created on the authorization button. - * @return A String with the player formatted with the player template - */ - private String formatPlayer(String playerTemplate, ApiBridgeHandler handler, String servletBaseURL) { - final Map map = new HashMap<>(); - - map.put(ACCOUNT_ID, handler.getUIDString()); - map.put(ACCOUNT_NAME, handler.getLabel()); - - if (handler.isConnected()) { - map.put(ACCOUNT_AUTHORIZED_CLASS, " authorized"); - } else { - map.put(ACCOUNT_AUTHORIZED_CLASS, " Unauthorized"); - } - - map.put(ACCOUNT_AUTHORIZE, handler.formatAuthorizationUrl()); - return replaceKeysFromMap(playerTemplate, map); - } - - /** - * Replaces all keys from the map found in the template with values from the map. If the key is not found the key - * will be kept in the template. - * - * @param template template to replace keys with values - * @param map map with key value pairs to replace in the template - * @return a template with keys replaced - */ - private String replaceKeysFromMap(String template, Map map) { - final Matcher m = MESSAGE_KEY_PATTERN.matcher(template); - final StringBuffer sb = new StringBuffer(); - - while (m.find()) { - try { - final String key = m.group(1); - m.appendReplacement(sb, Matcher.quoteReplacement(map.getOrDefault(key, "${" + key + '}'))); - } catch (RuntimeException e) { - logger.debug("Error occurred during template filling, cause ", e); - } - } - m.appendTail(sb); - return sb.toString(); - } - - @Override - public String getPath() { - return "connect"; - } -} diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/GrantServlet.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/GrantServlet.java new file mode 100644 index 0000000000000..f5574b183ab39 --- /dev/null +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/GrantServlet.java @@ -0,0 +1,152 @@ +/** + * Copyright (c) 2010-2022 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.netatmo.internal.servlet; + +import static org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.PARAM_ERROR; +import static org.openhab.core.auth.oauth2client.internal.Keyword.*; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +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.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.util.MultiMap; +import org.eclipse.jetty.util.UrlEncoded; +import org.openhab.binding.netatmo.internal.handler.ApiBridgeHandler; +import org.osgi.service.http.HttpService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link GrantServlet} manages the authorization with the Netatmo API. The servlet implements the + * Authorization Code flow and saves the resulting refreshToken with the bridge. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class GrantServlet extends NetatmoServlet { + private static final long serialVersionUID = 4817341543768441689L; + private static final Pattern MESSAGE_KEY_PATTERN = Pattern.compile("\\$\\{([^\\}]+)\\}"); + private static final String TEMPLATE_ACCOUNT = "template/account.html"; + private static final String CONTENT_TYPE = "text/html;charset=UTF-8"; + + // Simple HTML templates for inserting messages. + private static final String HTML_ERROR = "

Call to Netatmo Connect failed with error: %s

"; + + // Keys present in the account.html + private static final String KEY_ERROR = "error"; + private static final String ACCOUNT_NAME = "account.name"; + private static final String ACCOUNT_AUTHORIZED_CLASS = "account.authorized"; + private static final String ACCOUNT_AUTHORIZE = "account.authorize"; + + private final Logger logger = LoggerFactory.getLogger(GrantServlet.class); + private final @NonNullByDefault({}) ClassLoader classLoader = GrantServlet.class.getClassLoader(); + private final String accountTemplate; + + public GrantServlet(ApiBridgeHandler handler, HttpService httpService) { + super(handler, httpService, "connect"); + try (InputStream stream = classLoader.getResourceAsStream(TEMPLATE_ACCOUNT)) { + accountTemplate = stream != null ? new String(stream.readAllBytes(), StandardCharsets.UTF_8) : ""; + } catch (IOException e) { + throw new IllegalArgumentException("Unable to load template account file. Please file a bug report."); + } + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + logger.debug("Netatmo auth callback servlet received GET request {}.", req.getRequestURI()); + StringBuffer requestUrl = req.getRequestURL(); + if (requestUrl != null) { + final String servletBaseURL = requestUrl.toString(); + final Map replaceMap = new HashMap<>(); + + handleRedirect(replaceMap, servletBaseURL, req.getQueryString()); + + String label = handler.getThing().getLabel(); + replaceMap.put(ACCOUNT_NAME, label != null ? label : ""); + replaceMap.put(CLIENT_ID, handler.getId()); + replaceMap.put(ACCOUNT_AUTHORIZED_CLASS, handler.isConnected() ? " authorized" : " Unauthorized"); + replaceMap.put(ACCOUNT_AUTHORIZE, + handler.formatAuthorizationUrl().queryParam(REDIRECT_URI, servletBaseURL).build().toString()); + replaceMap.put(REDIRECT_URI, servletBaseURL); + + resp.setContentType(CONTENT_TYPE); + resp.getWriter().append(replaceKeysFromMap(accountTemplate, replaceMap)); + resp.getWriter().close(); + } else { + logger.warn("Unexpected : requestUrl is null"); + } + } + + /** + * Handles a possible call from Netatmo to the redirect_uri. If that is the case it will pass the authorization + * codes via the url and these are processed. In case of an error this is shown to the user. If the user was + * authorized this is passed on to the handler. Based on all these different outcomes the HTML is generated to + * inform the user. + * + * @param replaceMap a map with key String values that will be mapped in the HTML templates. + * @param servletBaseURL the servlet base, which should be used as the redirect_uri value + * @param queryString the query part of the GET request this servlet is processing + */ + private void handleRedirect(Map replaceMap, String servletBaseURL, @Nullable String queryString) { + replaceMap.put(KEY_ERROR, ""); + + if (queryString != null) { + final MultiMap<@Nullable String> params = new MultiMap<>(); + UrlEncoded.decodeTo(queryString, params, StandardCharsets.UTF_8.name()); + final String reqCode = params.getString(CODE); + final String reqState = params.getString(STATE); + final String reqError = params.getString(PARAM_ERROR); + + if (reqError != null) { + logger.debug("Netatmo redirected with an error: {}", reqError); + replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, reqError)); + } else if (reqState != null && reqCode != null) { + handler.openConnection(reqCode, servletBaseURL); + } + } + } + + /** + * Replaces all keys from the map found in the template with values from the map. If the key is not found the key + * will be kept in the template. + * + * @param template template to replace keys with values + * @param map map with key value pairs to replace in the template + * @return a template with keys replaced + */ + private String replaceKeysFromMap(String template, Map map) { + final Matcher m = MESSAGE_KEY_PATTERN.matcher(template); + final StringBuffer sb = new StringBuffer(); + + while (m.find()) { + try { + final String key = m.group(1); + m.appendReplacement(sb, Matcher.quoteReplacement(map.getOrDefault(key, "${" + key + '}'))); + } catch (RuntimeException e) { + logger.debug("Error occurred during template filling, cause ", e); + } + } + m.appendTail(sb); + return sb.toString(); + } +} diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/NetatmoServlet.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/NetatmoServlet.java index 8a5c5c707c87a..458aa38ba4829 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/NetatmoServlet.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/NetatmoServlet.java @@ -1,5 +1,63 @@ +/** + * Copyright (c) 2010-2022 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.netatmo.internal.servlet; -public interface NetatmoServlet { - String getPath(); +import static org.openhab.binding.netatmo.internal.NetatmoBindingConstants.BINDING_ID; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.netatmo.internal.handler.ApiBridgeHandler; +import org.osgi.service.http.HttpService; +import org.osgi.service.http.NamespaceException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link NetatmoServlet} is the ancestor class for Netatmo servlets + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public abstract class NetatmoServlet extends HttpServlet { + private static final long serialVersionUID = 5671438863935117735L; + private static final String BASE_PATH = "/" + BINDING_ID + "/"; + + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + private final HttpService httpService; + + protected final ApiBridgeHandler handler; + protected final String path; + + public NetatmoServlet(ApiBridgeHandler handler, HttpService httpService, String localPath) { + this.path = BASE_PATH + localPath + "/" + handler.getId(); + this.handler = handler; + this.httpService = httpService; + } + + public void startListening() { + try { + httpService.registerServlet(path, this, null, httpService.createDefaultHttpContext()); + logger.info("Registered Netatmo servlet at '{}'", path); + } catch (NamespaceException | ServletException e) { + logger.warn("Registering servlet failed:{}", e.getMessage()); + } + } + + public void dispose() { + logger.debug("Stopping Netatmo Servlet {}", path); + httpService.unregister(path); + this.destroy(); + } } diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/ServletService.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/ServletService.java deleted file mode 100644 index 30b692f757ed5..0000000000000 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/ServletService.java +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Copyright (c) 2010-2022 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.netatmo.internal.servlet; - -import static org.openhab.binding.netatmo.internal.NetatmoBindingConstants.BINDING_ID; - -import java.util.HashMap; -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.openhab.binding.netatmo.internal.handler.ApiBridgeHandler; -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; - -/** - * The {@link ServletService} class to manage the servlets and bind authorization servlet to bridges. - * - * @author Gaël L'hopital - Initial contribution - */ -@Component(service = ServletService.class) -@NonNullByDefault -public class ServletService { - private static final String BASE_PATH = "/" + BINDING_ID + "/"; - - private final Logger logger = LoggerFactory.getLogger(ServletService.class); - private final Map accountHandlers = new HashMap<>(); - private final HttpService httpService; - private final BundleContext bundleContext; - private final Map servlets = new HashMap<>(); - - @Activate - public ServletService(@Reference HttpService httpService, ComponentContext componentContext) { - this.httpService = httpService; - this.bundleContext = componentContext.getBundleContext(); - createAuthenticationServlet(); - } - - private void createAuthenticationServlet() { - AuthenticationServlet authServlet = new AuthenticationServlet(this, bundleContext); - registerServlet(authServlet, authServlet.getPath()); - } - - public WebhookServlet createWebhookServlet(ApiBridgeHandler apiBridgeHandler, String clientId, String webHookUrl) { - WebhookServlet webhookServlet = new WebhookServlet(apiBridgeHandler, webHookUrl, clientId); - registerServlet(webhookServlet, webhookServlet.getPath()); - return webhookServlet; - } - - private void registerServlet(HttpServlet servlet, String servletPath) { - String path = BASE_PATH + servletPath; - try { - httpService.registerServlet(path, servlet, new Hashtable<>(), httpService.createDefaultHttpContext()); - servlets.put(servletPath, servlet); - logger.info("Registered Netatmo {} servlet at '{}'", servlet.getClass().getName(), servletPath); - } catch (NamespaceException | ServletException e) { - logger.warn("Error during Netatmo authentication servlet startup", e.getMessage()); - } - } - - @Deactivate - protected void deactivate(ComponentContext componentContext) { - servlets.keySet().forEach(alias -> { - httpService.unregister(alias); - }); - servlets.clear(); - } - - /** - * @param listener Adds the given handler - */ - public void addAccountHandler(ApiBridgeHandler listener) { - accountHandlers.put(listener.getUIDString(), listener); - } - - /** - * @param handler Removes the given handler - */ - public void removeAccountHandler(ApiBridgeHandler apiBridge) { - // TODO Ca ne marche pas mais l'idée est là... - servlets.forEach((alias, handler) -> { - if (handler.equals(apiBridge)) { - WebhookServlet webhook = (WebhookServlet) servlets.remove(alias); - webhook.dispose(); - } - }); - accountHandlers.remove(apiBridge.getUIDString()); - } - - /** - * @return Returns all {@link ApiBridgeHandler}s. - */ - public Map getAccountHandlers() { - return accountHandlers; - } -} diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java index cf82638fe5a92..f0f9bf99d3388 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java @@ -12,8 +12,6 @@ */ package org.openhab.binding.netatmo.internal.servlet; -import static org.openhab.binding.netatmo.internal.NetatmoBindingConstants.BINDING_ID; - import java.io.IOException; import java.io.InputStream; import java.net.URI; @@ -21,13 +19,9 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Scanner; import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; -import javax.servlet.ServletException; -import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.HttpMethod; @@ -42,6 +36,7 @@ import org.openhab.binding.netatmo.internal.deserialization.NADeserializer; import org.openhab.binding.netatmo.internal.handler.ApiBridgeHandler; import org.openhab.binding.netatmo.internal.handler.capability.EventCapability; +import org.osgi.service.http.HttpService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -51,52 +46,50 @@ * @author Gaël L'hopital - Initial contribution */ @NonNullByDefault -public class WebhookServlet extends HttpServlet implements NetatmoServlet { +public class WebhookServlet extends NetatmoServlet { private static final long serialVersionUID = -354583910860541214L; - private final Logger logger = LoggerFactory.getLogger(WebhookServlet.class); private final Map dataListeners = new ConcurrentHashMap<>(); + private final Logger logger = LoggerFactory.getLogger(WebhookServlet.class); + private final SecurityApi securityApi; private final NADeserializer deserializer; - private final Optional securityApi; - private final String clientId; + private final String webHookUrl; private boolean hookSet = false; - public WebhookServlet(ApiBridgeHandler apiBridge, String webHookUrl, String clientId) { - this.deserializer = apiBridge.getDeserializer(); - this.clientId = clientId; - this.securityApi = Optional.ofNullable(apiBridge.getRestManager(SecurityApi.class)); - securityApi.ifPresent(api -> { - URI uri = UriBuilder.fromUri(webHookUrl).path(BINDING_ID).path(clientId).build(); - try { - logger.info("Setting Netatmo Welcome WebHook to {}", uri.toString()); - hookSet = api.addwebhook(uri); - } catch (UriBuilderException e) { - logger.info("webhookUrl is not a valid URI '{}' : {}", uri, e.getMessage()); - } catch (NetatmoException e) { - logger.info("Error setting webhook : {}", e.getMessage()); - } - }); + public WebhookServlet(ApiBridgeHandler handler, HttpService httpService, NADeserializer deserializer, + SecurityApi securityApi, String webHookUrl) { + super(handler, httpService, "webhook"); + this.deserializer = deserializer; + this.securityApi = securityApi; + this.webHookUrl = webHookUrl; } - public void dispose() { - if (hookSet) { - securityApi.ifPresent(api -> { - logger.info("Releasing Netatmo Welcome WebHook"); - try { - hookSet = api.dropWebhook(); - } catch (NetatmoException e) { - logger.warn("Error releasing webhook : {}", e.getMessage()); - } - // httpService.unregister(CALLBACK_URI); - }); + @Override + public void startListening() { + super.startListening(); + URI uri = UriBuilder.fromUri(webHookUrl).path(path).build(); + try { + logger.info("Setting up WebHook at Netatmo to {}", uri.toString()); + hookSet = securityApi.addwebhook(uri); + } catch (UriBuilderException e) { + logger.info("webhookUrl is not a valid URI '{}' : {}", uri, e.getMessage()); + } catch (NetatmoException e) { + logger.info("Error setting webhook : {}", e.getMessage()); } - logger.debug("Netatmo Webhook Servlet stopped"); } @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - logger.debug("Netatmo webhook callback servlet received GET request {}.", req.getRequestURI()); + public void dispose() { + if (hookSet) { + logger.info("Releasing WebHook at Netatmo "); + try { + securityApi.dropWebhook(); + hookSet = false; + } catch (NetatmoException e) { + logger.warn("Error releasing webhook : {}", e.getMessage()); + } + } } @Override @@ -106,13 +99,10 @@ protected void service(HttpServletRequest req, HttpServletResponse resp) throws logger.debug("Event transmitted from restService : {}", data); try { WebhookEvent event = deserializer.deserialize(WebhookEvent.class, data); - List tobeNotified = collectNotified(event); - dataListeners.keySet().stream().filter(tobeNotified::contains).forEach(id -> { - EventCapability module = dataListeners.get(id); - if (module != null) { - module.setNewData(event); - } - }); + List toBeNotified = new ArrayList<>(); + toBeNotified.add(event.getCameraId()); + toBeNotified.addAll(event.getPersons().keySet()); + notifyListeners(toBeNotified, event); } catch (NetatmoException e) { logger.info("Error deserializing webhook data received : {}. {}", data, e.getMessage()); } @@ -126,25 +116,6 @@ protected void service(HttpServletRequest req, HttpServletResponse resp) throws resp.getWriter().write(""); } - private List collectNotified(WebhookEvent event) { - List result = new ArrayList<>(); - result.add(event.getCameraId()); - String person = event.getPersonId(); - if (person != null) { - result.add(person); - } - result.addAll(event.getPersons().keySet()); - return result.stream().distinct().collect(Collectors.toList()); - } - - public void registerDataListener(String id, EventCapability dataListener) { - dataListeners.put(id, dataListener); - } - - public void unregisterDataListener(EventCapability dataListener) { - dataListeners.entrySet().removeIf(entry -> entry.getValue().equals(dataListener)); - } - private String inputStreamToString(InputStream is) throws IOException { String value = ""; try (Scanner scanner = new Scanner(is)) { @@ -154,8 +125,20 @@ private String inputStreamToString(InputStream is) throws IOException { return value; } - @Override - public String getPath() { - return "webhook/" + clientId; + public void notifyListeners(List tobeNotified, WebhookEvent event) { + tobeNotified.forEach(id -> { + EventCapability module = dataListeners.get(id); + if (module != null) { + module.setNewData(event); + } + }); + } + + public void registerDataListener(String id, EventCapability eventCapability) { + dataListeners.put(id, eventCapability); + } + + public void unregisterDataListener(String id) { + dataListeners.remove(id); } } diff --git a/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/config/config.xml index 6fc5292f01154..6be633ac23799 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/config/config.xml +++ b/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/config/config.xml @@ -17,19 +17,6 @@ password
- - - Authentication code, provided by the oAuth2 authentication process. - password - true - - - - - Redirection Uri used by Netatmo to reach the openHab server. Provided during authentication process. - true - - Refresh token provided by the oAuth2 authentication process. @@ -39,7 +26,7 @@ - Protocol, public IP and port to access openHAB server from Internet. + Protocol, public IP or hostname and port to access openHAB server from Internet. diff --git a/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/i18n/netatmo.properties b/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/i18n/netatmo.properties index 1da8f0382fb34..2d61654414174 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/i18n/netatmo.properties +++ b/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/i18n/netatmo.properties @@ -181,7 +181,7 @@ channel-type.netatmo.floodlight-mode.description = State of the floodlight (On/O channel-type.netatmo.floodlight-mode.state.option.ON = On channel-type.netatmo.floodlight-mode.state.option.OFF = Off channel-type.netatmo.floodlight-mode.state.option.AUTO = Auto -channel-type.netatmo.gust-angle.label = Gust AngleclientId +channel-type.netatmo.gust-angle.label = Gust Angle channel-type.netatmo.gust-angle.description = Direction of the last 5 minutes highest gust wind channel-type.netatmo.gust-strength.label = Gust Strength channel-type.netatmo.gust-strength.description = Speed of the last 5 minutes highest gust wind diff --git a/bundles/org.openhab.binding.netatmo/src/main/resources/template/account.html b/bundles/org.openhab.binding.netatmo/src/main/resources/template/account.html new file mode 100644 index 0000000000000..af0009481c151 --- /dev/null +++ b/bundles/org.openhab.binding.netatmo/src/main/resources/template/account.html @@ -0,0 +1,74 @@ + + + + + +Authorize openHAB Bridge at Netatmo Connect + + + + +

Authorize openHAB Bridge at Netatmo Connect

+

On this page you can authorize your openHAB Netatmo Bridge configured with the clientId and clientSecret of the Netatmo Application on your Developer account.

+

You have to login to your Netatmo Account and authorize this binding to access your account.

+

To use this binding the following requirements apply:

+
    +
  • A Netatmo connect account. +
  • Register openHAB as an App on your Netatmo Connect account. +
+

+ The redirect URI to use with Netatmo for this openHAB Netatmo Bridge is + ${redirect_uri} +

+ ${error} +
+ Connect to Netatmo: ${account.name} +

+
+ + + + diff --git a/bundles/org.openhab.binding.netatmo/src/main/resources/templates/account.html b/bundles/org.openhab.binding.netatmo/src/main/resources/templates/account.html deleted file mode 100644 index fce6b4c71d317..0000000000000 --- a/bundles/org.openhab.binding.netatmo/src/main/resources/templates/account.html +++ /dev/null @@ -1,4 +0,0 @@ -
- Connect to Netatmo: ${account.name} -

-
diff --git a/bundles/org.openhab.binding.netatmo/src/main/resources/templates/index.html b/bundles/org.openhab.binding.netatmo/src/main/resources/templates/index.html deleted file mode 100644 index de8cbc8a66086..0000000000000 --- a/bundles/org.openhab.binding.netatmo/src/main/resources/templates/index.html +++ /dev/null @@ -1,68 +0,0 @@ - - - - - -${pageRefresh} -Authorize openHAB binding for Netatmo - - - - -

Authorize openHAB binding for Netatmo

-

On this page you can authorize your openHAB Netatmo Bridge configured with the clientId and clientSecret of the Netatmo Application on your Developer account, you have to login to your Netatmo Account and authorize this binding to access your account.

-

To use this binding the following requirements apply:

-
    -
  • A Netatmo connect account. -
  • Register openHAB as an App on your Netatmo Connect account. -
-

- The redirect URI to use with Netatmo for this openHAB installation is - ${redirectUri} -

- ${error} ${accounts} - - From 1e7a1b93be240b7f073f3d37519280aa97dc46f1 Mon Sep 17 00:00:00 2001 From: clinique Date: Wed, 18 May 2022 16:05:44 +0200 Subject: [PATCH 3/7] Some leftovers forgotten Signed-off-by: clinique --- bundles/org.openhab.binding.netatmo/README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/bundles/org.openhab.binding.netatmo/README.md b/bundles/org.openhab.binding.netatmo/README.md index a8064dd214205..1544b4e332d50 100644 --- a/bundles/org.openhab.binding.netatmo/README.md +++ b/bundles/org.openhab.binding.netatmo/README.md @@ -40,19 +40,19 @@ You will have to create at first a bridge to handle communication with your Neta The Account bridge has the following configuration elements: -| Parameter | Type | Required | Description | -|-------------------|--------|----------|----------------------------------------------------------------------------------------------------------------------------| -| clientId | String | Yes | Client ID provided for the application you created on http://dev.netatmo.com/createapp | -| clientSecret | String | Yes | Client Secret provided for the application you created | -| webHookUrl | String | No | Protocol, public IP and port to access openHAB server from Internet | -| reconnectInterval | Number | No | The reconnection interval to Netatmo API (in s) | -| refreshToken | String | No | The refresh token provided by Netatmo API after the granting process. Can be saved for in case of file based configuration | +| Parameter | Type | Required | Description | +|-------------------|--------|----------|------------------------------------------------------------------------------------------------------------------------| +| clientId | String | Yes | Client ID provided for the application you created on http://dev.netatmo.com/createapp | +| clientSecret | String | Yes | Client Secret provided for the application you created | +| webHookUrl | String | No | Protocol, public IP and port to access openHAB server from Internet | +| reconnectInterval | Number | No | The reconnection interval to Netatmo API (in s) | +| refreshToken | String | No | The refresh token provided by Netatmo API after the granting process. Can be saved in case of file based configuration | -### Configure the Dridge +### Configure the Bridge 1. Complete the Netatmo Application Registration if you have not already done so, see above. 1. Make sure you have your _Client ID_ and _Client Secret_ identities available. -1. Add a new **"Netatmo Account"** thing. Choose new Id for the account, unless you like the generated one, put in the _Client ID_ and _Client Secret_ from the Spotify Application registration in their respective fields of the bridge configuration. Save the bridge. +1. Add a new **"Netatmo Account"** thing. Choose new Id for the account, unless you like the generated one, put in the _Client ID_ and _Client Secret_ from the Netatmo Connect Application registration in their respective fields of the bridge configuration. Save the bridge. 1. The bridge thing will go _OFFLINE_ / _CONFIGURATION_ERROR_ - this is fine. You have to authorize this bridge with Netatmo Connect. 1. Go to the authorization page of your server. `http://:8080/netatmo/connect/<_CLIENT_ID_>`. Your newly added bridge should be listed there. 1. Press the _"Authorize Thing"_ button. This will take you either to the login page of Netatmo Connect or directly to the authorization screen. Login and/or authorize the application. You will be returned and the entry should go green. From dbc0c10a426feb67b00b35f364795e918abbc816 Mon Sep 17 00:00:00 2001 From: clinique Date: Wed, 18 May 2022 18:45:09 +0200 Subject: [PATCH 4/7] Code review par 1 Signed-off-by: clinique --- bundles/org.openhab.binding.netatmo/README.md | 4 ++-- .../binding/netatmo/internal/config/ConfigurationLevel.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bundles/org.openhab.binding.netatmo/README.md b/bundles/org.openhab.binding.netatmo/README.md index 1544b4e332d50..2617a4f50a8bd 100644 --- a/bundles/org.openhab.binding.netatmo/README.md +++ b/bundles/org.openhab.binding.netatmo/README.md @@ -21,7 +21,7 @@ Follow instructions under: 1. Registering Your Application 1. Setting Redirect URI and webhook URI can be skipped, these will be provided by the binding. -The variables you will need to get to setup the binding are: +Variables needed to for the setup of the binding are: * `` Your client ID taken from your App at https://dev.netatmo.com/apps * `` A token provided along with the ``. @@ -54,7 +54,7 @@ The Account bridge has the following configuration elements: 1. Make sure you have your _Client ID_ and _Client Secret_ identities available. 1. Add a new **"Netatmo Account"** thing. Choose new Id for the account, unless you like the generated one, put in the _Client ID_ and _Client Secret_ from the Netatmo Connect Application registration in their respective fields of the bridge configuration. Save the bridge. 1. The bridge thing will go _OFFLINE_ / _CONFIGURATION_ERROR_ - this is fine. You have to authorize this bridge with Netatmo Connect. -1. Go to the authorization page of your server. `http://:8080/netatmo/connect/<_CLIENT_ID_>`. Your newly added bridge should be listed there. +1. Go to the authorization page of your server. `http://:8080/netatmo/connect/<_CLIENT_ID_>`. Your newly added bridge should be listed there (no need for you to expose your openHAB server outside your local network for this). 1. Press the _"Authorize Thing"_ button. This will take you either to the login page of Netatmo Connect or directly to the authorization screen. Login and/or authorize the application. You will be returned and the entry should go green. 1. The binding will be updated with a refresh token and go _ONLINE_. The refresh token is used to re-authorize the bridge with Netatmo Connect Web API whenever required. diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java index a7d29fbf11540..35f4aebc31e70 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java @@ -21,7 +21,7 @@ */ @NonNullByDefault public enum ConfigurationLevel { - EMPTY_CLIENT_ID("@text/conf-error-no-client-secret"), + EMPTY_CLIENT_ID("@text/conf-error-no-client-id"), EMPTY_CLIENT_SECRET("@text/conf-error-no-client-secret"), REFRESH_TOKEN_NEEDED("@text/conf-error-grant-needed"), COMPLETED(""); From 15ae03b64620028d81f6725d460612d80c4cdc75 Mon Sep 17 00:00:00 2001 From: clinique Date: Fri, 20 May 2022 00:23:37 +0200 Subject: [PATCH 5/7] Lolodomo code review 2 Signed-off-by: clinique --- bundles/org.openhab.binding.netatmo/README.md | 2 +- .../netatmo/internal/config/ConfigurationLevel.java | 2 +- .../netatmo/internal/handler/ApiBridgeHandler.java | 9 ++++++--- .../netatmo/internal/servlet/WebhookServlet.java | 13 ++++++++++--- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/bundles/org.openhab.binding.netatmo/README.md b/bundles/org.openhab.binding.netatmo/README.md index 2617a4f50a8bd..a43561ae50dc9 100644 --- a/bundles/org.openhab.binding.netatmo/README.md +++ b/bundles/org.openhab.binding.netatmo/README.md @@ -535,7 +535,7 @@ All these channels except at-home are read only. ## things/netatmo.things ``` -Bridge netatmo:account:home "Netatmo Account" [clientId="", clientSecret="", username="", password=""] { +Bridge netatmo:account:home "Netatmo Account" [clientId="xxxxx", clientSecret="yyyy", refreshToken="zzzzz"] { Bridge weather-station inside "Inside Weather Station" [id="70:ee:aa:aa:aa:aa"] { outdoor outside "Outside Module" [id="02:00:00:aa:aa:aa"] { Channels: diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java index 35f4aebc31e70..2b082d7575a81 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java @@ -15,7 +15,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; /** - * The {@link ConfigurationLevel} describe configuration levels of a given account thing + * The {@link ConfigurationLevel} describes configuration levels of a given account thing * * @author Gaël L'hopital - Initial contribution */ diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java index c186138cafdfa..e4f0e65bf18d6 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java @@ -127,9 +127,12 @@ public void openConnection(@Nullable String code, @Nullable String redirectUri) String refreshToken = connectApi.authorize(configuration, bindingConf.features, code, redirectUri); - Configuration thingConfig = editConfiguration(); - thingConfig.put(ApiHandlerConfiguration.REFRESH_TOKEN, refreshToken); - updateConfiguration(thingConfig); + if (configuration.refreshToken.isBlank()) { + Configuration thingConfig = editConfiguration(); + thingConfig.put(ApiHandlerConfiguration.REFRESH_TOKEN, refreshToken); + updateConfiguration(thingConfig); + configuration = getConfiguration(); + } if (!configuration.webHookUrl.isBlank()) { SecurityApi securityApi = getRestManager(SecurityApi.class); diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java index f0f9bf99d3388..29b0f619aa1bb 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java @@ -94,7 +94,11 @@ public void dispose() { @Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException { - String data = inputStreamToString(req.getInputStream()); + replyQuick(resp); + processEvent(inputStreamToString(req.getInputStream())); + } + + private void processEvent(String data) throws IOException { if (!data.isEmpty()) { logger.debug("Event transmitted from restService : {}", data); try { @@ -104,9 +108,12 @@ protected void service(HttpServletRequest req, HttpServletResponse resp) throws toBeNotified.addAll(event.getPersons().keySet()); notifyListeners(toBeNotified, event); } catch (NetatmoException e) { - logger.info("Error deserializing webhook data received : {}. {}", data, e.getMessage()); + logger.debug("Error deserializing webhook data received : {}. {}", data, e.getMessage()); } } + } + + private void replyQuick(HttpServletResponse resp) throws IOException { resp.setCharacterEncoding(StandardCharsets.UTF_8.name()); resp.setContentType(MediaType.APPLICATION_JSON); resp.setHeader("Access-Control-Allow-Origin", "*"); @@ -125,7 +132,7 @@ private String inputStreamToString(InputStream is) throws IOException { return value; } - public void notifyListeners(List tobeNotified, WebhookEvent event) { + private void notifyListeners(List tobeNotified, WebhookEvent event) { tobeNotified.forEach(id -> { EventCapability module = dataListeners.get(id); if (module != null) { From 7b1aae6d5246d24520486a3715bc60cb37592dfc Mon Sep 17 00:00:00 2001 From: clinique Date: Fri, 20 May 2022 09:07:13 +0200 Subject: [PATCH 6/7] Adding mentions about the mandatory aspect of refreshToken. Signed-off-by: clinique --- bundles/org.openhab.binding.netatmo/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.netatmo/README.md b/bundles/org.openhab.binding.netatmo/README.md index a43561ae50dc9..29b63ee7aedee 100644 --- a/bundles/org.openhab.binding.netatmo/README.md +++ b/bundles/org.openhab.binding.netatmo/README.md @@ -46,7 +46,9 @@ The Account bridge has the following configuration elements: | clientSecret | String | Yes | Client Secret provided for the application you created | | webHookUrl | String | No | Protocol, public IP and port to access openHAB server from Internet | | reconnectInterval | Number | No | The reconnection interval to Netatmo API (in s) | -| refreshToken | String | No | The refresh token provided by Netatmo API after the granting process. Can be saved in case of file based configuration | +| refreshToken | String | Yes* | The refresh token provided by Netatmo API after the granting process. Can be saved in case of file based configuration | + +(*) Strictly said this parameter is not mandatory at first run, until you grant your binding on Netatmo Connect. Once present, you'll not have to grant again. ### Configure the Bridge @@ -57,6 +59,7 @@ The Account bridge has the following configuration elements: 1. Go to the authorization page of your server. `http://:8080/netatmo/connect/<_CLIENT_ID_>`. Your newly added bridge should be listed there (no need for you to expose your openHAB server outside your local network for this). 1. Press the _"Authorize Thing"_ button. This will take you either to the login page of Netatmo Connect or directly to the authorization screen. Login and/or authorize the application. You will be returned and the entry should go green. 1. The binding will be updated with a refresh token and go _ONLINE_. The refresh token is used to re-authorize the bridge with Netatmo Connect Web API whenever required. +1. If you're using file based .things config file, copy the provided refresh token in the **refreshToken** parameter of your thing definition (example below). Now that you have got your bridge _ONLINE_ you can now start a scan with the binding to auto discover your things. From 98052f16d8bd3384af7bc632664f8d2526677c89 Mon Sep 17 00:00:00 2001 From: clinique Date: Fri, 20 May 2022 12:43:32 +0200 Subject: [PATCH 7/7] Last modifications ? Signed-off-by: clinique --- bundles/org.openhab.binding.netatmo/README.md | 2 +- .../binding/netatmo/internal/servlet/WebhookServlet.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.netatmo/README.md b/bundles/org.openhab.binding.netatmo/README.md index 29b63ee7aedee..078da46b6607b 100644 --- a/bundles/org.openhab.binding.netatmo/README.md +++ b/bundles/org.openhab.binding.netatmo/README.md @@ -21,7 +21,7 @@ Follow instructions under: 1. Registering Your Application 1. Setting Redirect URI and webhook URI can be skipped, these will be provided by the binding. -Variables needed to for the setup of the binding are: +Variables needed for the setup of the binding are: * `` Your client ID taken from your App at https://dev.netatmo.com/apps * `` A token provided along with the ``. diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java index 29b0f619aa1bb..378081e296062 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java @@ -90,6 +90,7 @@ public void dispose() { logger.warn("Error releasing webhook : {}", e.getMessage()); } } + super.dispose(); } @Override