From 9632a0a870cb6dce82a0d424ccd844b1ee9dd0e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20L=27hopital?= Date: Fri, 20 May 2022 12:53:53 +0200 Subject: [PATCH] [netatmo] Switch to Code Granting process (#12726) Signed-off-by: clinique --- bundles/org.openhab.binding.netatmo/README.md | 53 ++++-- .../internal/NetatmoBindingConstants.java | 3 - .../internal/NetatmoHandlerFactory.java | 12 +- .../internal/api/AuthenticationApi.java | 73 +++++--- .../internal/api/NetatmoException.java | 3 +- .../netatmo/internal/api/SecurityApi.java | 3 +- .../internal/api/data/NetatmoConstants.java | 7 +- .../config/ApiHandlerConfiguration.java | 48 ++---- .../internal/config/ConfigurationLevel.java | 34 ++++ .../internal/config/NAThingConfiguration.java | 2 + .../discovery/NetatmoDiscoveryService.java | 16 +- .../internal/handler/ApiBridgeHandler.java | 152 ++++++++++------ .../internal/handler/CommonInterface.java | 2 +- .../handler/capability/EventCapability.java | 15 +- .../providers/NetatmoThingTypeProvider.java | 4 +- .../internal/servlet/GrantServlet.java | 152 ++++++++++++++++ .../internal/servlet/NetatmoServlet.java | 63 +++++++ .../internal/servlet/WebhookServlet.java | 152 ++++++++++++++++ .../internal/webhook/NetatmoServlet.java | 163 ------------------ .../main/resources/OH-INF/config/config.xml | 14 +- .../resources/OH-INF/i18n/netatmo.properties | 3 +- .../src/main/resources/template/account.html | 74 ++++++++ 22 files changed, 715 insertions(+), 333 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/GrantServlet.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/WebhookServlet.java delete mode 100644 bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/webhook/NetatmoServlet.java create mode 100644 bundles/org.openhab.binding.netatmo/src/main/resources/template/account.html diff --git a/bundles/org.openhab.binding.netatmo/README.md b/bundles/org.openhab.binding.netatmo/README.md index 9dccea907..078da46b6 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 -The variables you will need to get to setup the binding are: +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. + +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 ``. -* `` 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,34 @@ 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: -- **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). +| 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 | 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 + +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 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 (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. ## List of supported things @@ -73,7 +92,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 +102,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. @@ -519,7 +538,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/NetatmoBindingConstants.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/NetatmoBindingConstants.java index 481cfc06d..fc556be85 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 0ab898bbc..4bd697b85 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 @@ -68,11 +68,11 @@ import org.slf4j.LoggerFactory; public class NetatmoHandlerFactory extends BaseThingHandlerFactory { private final Logger logger = LoggerFactory.getLogger(NetatmoHandlerFactory.class); - private final NetatmoDescriptionProvider stateDescriptionProvider; - private final HttpClient httpClient; - private final NADeserializer deserializer; - private final HttpService httpService; private final BindingConfiguration configuration = new BindingConfiguration(); + private final NetatmoDescriptionProvider stateDescriptionProvider; + private final NADeserializer deserializer; + private final HttpClient httpClient; + private final HttpService httpService; @Activate public NetatmoHandlerFactory(@Reference NetatmoDescriptionProvider stateDescriptionProvider, @@ -80,8 +80,8 @@ public class NetatmoHandlerFactory extends BaseThingHandlerFactory { @Reference HttpService httpService, Map config) { this.stateDescriptionProvider = stateDescriptionProvider; this.httpClient = factory.getCommonHttpClient(); - this.httpService = httpService; this.deserializer = deserializer; + this.httpService = httpService; configChanged(config); } @@ -107,7 +107,7 @@ public class NetatmoHandlerFactory extends BaseThingHandlerFactory { private BaseThingHandler buildHandler(Thing thing, ModuleType moduleType) { if (ModuleType.ACCOUNT.equals(moduleType)) { - return new ApiBridgeHandler((Bridge) thing, httpClient, httpService, deserializer, configuration); + return new ApiBridgeHandler((Bridge) thing, httpClient, deserializer, configuration, httpService); } CommonInterface handler = moduleType.isABridge() ? new DeviceHandler((Bridge) thing) : new ModuleHandler(thing); 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 2da5144ae..5aabc4194 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.ScheduledExecutorService; 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,57 @@ import org.slf4j.LoggerFactory; */ @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, @Nullable String code, + @Nullable String redirectUri) 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 { + if (code != null && redirectUri != null) { + 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 +101,8 @@ public class AuthenticationApi extends RestManager { } 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 +110,20 @@ public class AuthenticationApi extends RestManager { } 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 UriBuilder getAuthorizationBuilder(String clientId, Set features) { + return AUTH_BUILDER.clone().queryParam(CLIENT_ID, clientId).queryParam(SCOPE, toScopeString(features)) + .queryParam(STATE, clientId); } } 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 709f205e8..79564cb62 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 class NetatmoException extends IOException { 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 3b4977117..a04f2fc79 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 @@ -56,9 +56,10 @@ public class SecurityApi extends RestManager { * @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 1a066b9a5..69756ac2b 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 class NetatmoConstants { // 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 PATH_STATE = "setstate"; @@ -148,6 +150,9 @@ public class NetatmoConstants { 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 782d04c06..b7a02105d 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,23 @@ import org.openhab.binding.netatmo.internal.api.NetatmoException; */ @NonNullByDefault public class ApiHandlerConfiguration { - public class Credentials { - public final String clientId, clientSecret, username, password; + public static final String CLIENT_ID = "clientId"; + 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 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.REFRESH_TOKEN_NEEDED; + } + 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 new file mode 100644 index 000000000..2b082d757 --- /dev/null +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java @@ -0,0 +1,34 @@ +/** + * 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} describes 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-id"), + EMPTY_CLIENT_SECRET("@text/conf-error-no-client-secret"), + REFRESH_TOKEN_NEEDED("@text/conf-error-grant-needed"), + COMPLETED(""); + + 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/config/NAThingConfiguration.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/NAThingConfiguration.java index 621dac474..7ed9f73dc 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 @@ import org.eclipse.jdt.annotation.NonNullByDefault; */ @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 e1ec6ff24..523ff2d00 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.ModuleType; 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 class NetatmoDiscoveryService extends AbstractDiscoveryService implements @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 class NetatmoDiscoveryService extends AbstractDiscoveryService implements 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 @@ public class NetatmoDiscoveryService extends AbstractDiscoveryService implements 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 @@ public class NetatmoDiscoveryService extends AbstractDiscoveryService implements 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 e0d7fe5c1..e4f0e65bf 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.ScheduledFuture; 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,16 @@ import org.openhab.binding.netatmo.internal.api.ApiError; 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.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.GrantServlet; +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; @@ -73,64 +78,95 @@ public class ApiBridgeHandler extends BaseBridgeHandler { private final Logger logger = LoggerFactory.getLogger(ApiBridgeHandler.class); private final BindingConfiguration bindingConf; - private final HttpService httpService; private final AuthenticationApi connectApi; private final HttpClient httpClient; private final NADeserializer deserializer; + private final HttpService httpService; private Optional> connectJob = Optional.empty(); - private Optional servlet = Optional.empty(); - private @NonNullByDefault({}) ApiHandlerConfiguration thingConf; - private Map, RestManager> managers = new HashMap<>(); + private @Nullable WebhookServlet webHookServlet; + private @Nullable GrantServlet grantServlet; - public ApiBridgeHandler(Bridge bridge, HttpClient httpClient, HttpService httpService, NADeserializer deserializer, - BindingConfiguration configuration) { + public ApiBridgeHandler(Bridge bridge, HttpClient httpClient, NADeserializer deserializer, + BindingConfiguration configuration, HttpService httpService) { super(bridge); this.bindingConf = configuration; - this.httpService = httpService; 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."); - 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(null, null)); } - 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()); + 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: + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, level.message); + break; + 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(configuration, bindingConf.features, code, redirectUri); + + 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); + 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); + + } catch (NetatmoException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + 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(), thingConf.reconnectInterval, TimeUnit.SECONDS)); + connectJob = Optional.of(scheduler.schedule(() -> openConnection(code, redirectUri), + getConfiguration().reconnectInterval, TimeUnit.SECONDS)); } private void freeConnectJob() { @@ -141,8 +177,14 @@ public class ApiBridgeHandler extends BaseBridgeHandler { @Override public void dispose() { logger.debug("Shutting down Netatmo API bridge handler."); - servlet.ifPresent(servlet -> servlet.dispose()); - servlet = Optional.empty(); + WebhookServlet localWebHook = this.webHookServlet; + if (localWebHook != null) { + localWebHook.dispose(); + } + GrantServlet localGrant = this.grantServlet; + if (localGrant != null) { + localGrant.dispose(); + } connectApi.dispose(); freeConnectJob(); super.dispose(); @@ -153,11 +195,6 @@ public class ApiBridgeHandler extends BaseBridgeHandler { 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)) { @@ -218,24 +255,33 @@ public class ApiBridgeHandler extends BaseBridgeHandler { 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 servlet; - } - - public NADeserializer getDeserializer() { - return deserializer; + public boolean getReadFriends() { + return bindingConf.readFriends; } public boolean isConnected() { return connectApi.isConnected(); } + + public String getId() { + return (String) getThing().getConfiguration().get(ApiHandlerConfiguration.CLIENT_ID); + } + + public UriBuilder formatAuthorizationUrl() { + return AuthenticationApi.getAuthorizationBuilder(getId(), bindingConf.features); + } + + @Override + public Collection> getServices() { + return Set.of(NetatmoDiscoveryService.class); + } + + 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 aa519e21b..adec7694d 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 @@ public interface CommonInterface { } 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 a72abc406..aa4e29498 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,19 +17,18 @@ import java.util.Optional; 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 - * 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 class EventCapability extends Capability { 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 0e81eb687..7f5608beb 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 java.util.stream.Collectors; 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 class NetatmoThingTypeProvider implements ThingTypeProvider { 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/GrantServlet.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/GrantServlet.java new file mode 100644 index 000000000..f5574b183 --- /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 new file mode 100644 index 000000000..458aa38ba --- /dev/null +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/NetatmoServlet.java @@ -0,0 +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; + +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/WebhookServlet.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java new file mode 100644 index 000000000..378081e29 --- /dev/null +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.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 java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Scanner; +import java.util.concurrent.ConcurrentHashMap; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.HttpMethod; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.UriBuilder; +import javax.ws.rs.core.UriBuilderException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * HTTP servlet for Netatmo Webhook. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class WebhookServlet extends NetatmoServlet { + private static final long serialVersionUID = -354583910860541214L; + + private final Map dataListeners = new ConcurrentHashMap<>(); + private final Logger logger = LoggerFactory.getLogger(WebhookServlet.class); + private final SecurityApi securityApi; + private final NADeserializer deserializer; + private final String webHookUrl; + + private boolean hookSet = false; + + 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; + } + + @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()); + } + } + + @Override + 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()); + } + } + super.dispose(); + } + + @Override + protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException { + replyQuick(resp); + processEvent(inputStreamToString(req.getInputStream())); + } + + private void processEvent(String data) throws IOException { + if (!data.isEmpty()) { + logger.debug("Event transmitted from restService : {}", data); + try { + WebhookEvent event = deserializer.deserialize(WebhookEvent.class, data); + List toBeNotified = new ArrayList<>(); + toBeNotified.add(event.getCameraId()); + toBeNotified.addAll(event.getPersons().keySet()); + notifyListeners(toBeNotified, event); + } catch (NetatmoException e) { + 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", "*"); + 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 String inputStreamToString(InputStream is) throws IOException { + String value = ""; + try (Scanner scanner = new Scanner(is)) { + scanner.useDelimiter("\\A"); + value = scanner.hasNext() ? scanner.next() : ""; + } + return value; + } + + private 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/java/org/openhab/binding/netatmo/internal/webhook/NetatmoServlet.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/webhook/NetatmoServlet.java deleted file mode 100644 index ffd091afe..000000000 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/webhook/NetatmoServlet.java +++ /dev/null @@ -1,163 +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.webhook; - -import static org.openhab.binding.netatmo.internal.NetatmoBindingConstants.BINDING_ID; - -import java.io.IOException; -import java.io.InputStream; -import java.net.URI; -import java.nio.charset.StandardCharsets; -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; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.UriBuilder; -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; - -/** - * HTTP servlet for Netatmo Webhook. - * - * @author Gaël L'hopital - Initial contribution - */ -@NonNullByDefault -public class NetatmoServlet extends HttpServlet { - private static final long serialVersionUID = -354583910860541214L; - private static final String CALLBACK_URI = "/" + BINDING_ID; - - private final Logger logger = LoggerFactory.getLogger(NetatmoServlet.class); - private final Map dataListeners = new ConcurrentHashMap<>(); - private final HttpService httpService; - private final NADeserializer deserializer; - private final Optional securityApi; - private boolean hookSet = false; - - public NetatmoServlet(HttpService httpService, ApiBridgeHandler apiBridge, String webHookUrl) { - this.httpService = httpService; - this.deserializer = apiBridge.getDeserializer(); - this.securityApi = Optional.ofNullable(apiBridge.getRestManager(SecurityApi.class)); - securityApi.ifPresent(api -> { - 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()); - } - }); - } - - public void dispose() { - securityApi.ifPresent(api -> { - if (hookSet) { - logger.info("Releasing Netatmo Welcome WebHook"); - try { - api.dropWebhook(); - } catch (NetatmoException e) { - logger.warn("Error releasing webhook : {}", e.getMessage()); - } - } - 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()); - } - } - 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) { - 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)) { - scanner.useDelimiter("\\A"); - value = scanner.hasNext() ? scanner.next() : ""; - } - return value; - } -} 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 e7023f6ce..6be633ac2 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,20 +17,16 @@ password - - - Your Netatmo API username (email). - - - - - Your Netatmo API password. + + + Refresh token provided by the oAuth2 authentication process. password + true - 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 cc52514f7..2d6165441 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 @@ -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/template/account.html b/bundles/org.openhab.binding.netatmo/src/main/resources/template/account.html new file mode 100644 index 000000000..af0009481 --- /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} +

+
+ + + +