]> git.basschouten.com Git - openhab-addons.git/commitdiff
[netatmo] Switch to Code Granting process (#12726)
authorGaël L'hopital <gael@lhopital.org>
Fri, 20 May 2022 10:53:53 +0000 (12:53 +0200)
committerGitHub <noreply@github.com>
Fri, 20 May 2022 10:53:53 +0000 (12:53 +0200)
Signed-off-by: clinique <gael@lhopital.org>
22 files changed:
bundles/org.openhab.binding.netatmo/README.md
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/NetatmoBindingConstants.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/NetatmoHandlerFactory.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/AuthenticationApi.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/NetatmoException.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/SecurityApi.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/data/NetatmoConstants.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ApiHandlerConfiguration.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ConfigurationLevel.java [new file with mode: 0644]
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/NAThingConfiguration.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/discovery/NetatmoDiscoveryService.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/CommonInterface.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/capability/EventCapability.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/providers/NetatmoThingTypeProvider.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/GrantServlet.java [new file with mode: 0644]
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/NetatmoServlet.java [new file with mode: 0644]
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/servlet/WebhookServlet.java [new file with mode: 0644]
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/webhook/NetatmoServlet.java [deleted file]
bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/config/config.xml
bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/i18n/netatmo.properties
bundles/org.openhab.binding.netatmo/src/main/resources/template/account.html [new file with mode: 0644]

index 9dccea907597a5a06aee050c27af779adc5edb08..078da46b6607be030859d519ac429628a81651c7 100644 (file)
@@ -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:
 
 * `<CLIENT_ID>` Your client ID taken from your App at https://dev.netatmo.com/apps
 * `<CLIENT_SECRET>` A token provided along with the `<CLIENT_ID>`.
-* `<USERNAME>` The username you use to connect to the Netatmo API (usually your mail address).
-* `<PASSWORD>` 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:
+
+| 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://<your openHAB address>: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).
 
--   **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 +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:
index 481cfc06d910441c07ef44ffdf2caee8a8f02a4c..fc556be85fae256e9f798a5cf3a6ea37c5a320ec 100644 (file)
@@ -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";
index 0ab898bbc95d9c2317550bf06db7c8770d06f4f4..4bd697b859ecc2933ba619dc654a521a28567c08 100644 (file)
@@ -68,11 +68,11 @@ import org.slf4j.LoggerFactory;
 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 HttpClient httpClient;
     private final HttpService httpService;
-    private final BindingConfiguration configuration = new BindingConfiguration();
 
     @Activate
     public NetatmoHandlerFactory(@Reference NetatmoDescriptionProvider stateDescriptionProvider,
@@ -80,8 +80,8 @@ public class NetatmoHandlerFactory extends BaseThingHandlerFactory {
             @Reference HttpService httpService, Map<String, @Nullable Object> 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);
 
index 2da5144aef2c8142713584f4f71d5acb3b98ab1a..5aabc4194ec2238accd33dcee25f8910e3e98623 100644 (file)
@@ -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<ScheduledFuture<?>> refreshTokenJob = Optional.empty();
     private Optional<AccessTokenResponse> 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<FeatureArea> features) throws NetatmoException {
-        Set<FeatureArea> 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<FeatureArea> features, @Nullable String code,
+            @Nullable String redirectUri) throws NetatmoException {
+        String clientId = credentials.clientId;
+        String clientSecret = credentials.clientSecret;
+        if (!(clientId.isBlank() || clientSecret.isBlank())) {
+            Map<String, String> 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<String, String> entries) throws NetatmoException {
+    private String requestToken(String id, String secret, Map<String, String> entries) throws NetatmoException {
         Map<String, String> 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<Scope> 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<FeatureArea> features) {
+        return FeatureArea.toScopeString(features.isEmpty() ? FeatureArea.AS_SET : features);
+    }
+
+    public static UriBuilder getAuthorizationBuilder(String clientId, Set<FeatureArea> features) {
+        return AUTH_BUILDER.clone().queryParam(CLIENT_ID, clientId).queryParam(SCOPE, toScopeString(features))
+                .queryParam(STATE, clientId);
     }
 }
index 709f205e80828a7b603f7adbf539b0926ccc5761..79564cb62fceaecaf151323ce358289e05818178 100644 (file)
@@ -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);
     }
 }
index 3b497711787c8b0e0a2a52ab7d5967da0f8017d7..a04f2fc7995c9f8ef613d5baa5ab22ae31cf3822 100644 (file)
@@ -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<HomeEvent> getPersonEvents(String homeId, String personId) throws NetatmoException {
index 1a066b9a51418964e7723e2bf41d21dca56a972f..69756ac2b193e89896db8d7d8481b1b585d2406f 100644 (file)
@@ -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;
 
index 782d04c06879c5cae8ec2dfc7c6e727d5b171fa7..b7a02105d5109f1cba258298c2859a3477949d70 100644 (file)
@@ -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 (file)
index 0000000..2b082d7
--- /dev/null
@@ -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;
+    }
+}
index 621dac474faa547e37291ddf0c52d6e81cc6c86d..7ed9f73dc21ce90f0cd1da22866c3b1f8ef4ff3d 100644 (file)
@@ -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;
 }
index e1ec6ff247ef40344b605ee4d1b443e33487a0ee..523ff2d009e07ff87b82a10dddbb46c9b9db8f00 100644 (file)
@@ -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<NAMain> 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();
         }
     }
 
index e0d7fe5c1a6d341d6fda0759c7864c3122d17f5e..e4f0e65bf18d6b3bd8b45c758c27f326b7aeeb46 100644 (file)
@@ -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<ScheduledFuture<?>> connectJob = Optional.empty();
-    private Optional<NetatmoServlet> servlet = Optional.empty();
-    private @NonNullByDefault({}) ApiHandlerConfiguration thingConf;
-
     private Map<Class<? extends RestManager>, 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<Class<? extends ThingHandlerService>> getServices() {
-        return Set.of(NetatmoDiscoveryService.class);
-    }
-
     @SuppressWarnings("unchecked")
     public <T extends RestManager> @Nullable T getRestManager(Class<T> 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 boolean getReadFriends() {
+        return bindingConf.readFriends;
+    }
+
+    public boolean isConnected() {
+        return connectApi.isConnected();
     }
 
-    public Optional<NetatmoServlet> getServlet() {
-        return servlet;
+    public String getId() {
+        return (String) getThing().getConfiguration().get(ApiHandlerConfiguration.CLIENT_ID);
     }
 
-    public NADeserializer getDeserializer() {
-        return deserializer;
+    public UriBuilder formatAuthorizationUrl() {
+        return AuthenticationApi.getAuthorizationBuilder(getId(), bindingConf.features);
     }
 
-    public boolean isConnected() {
-        return connectApi.isConnected();
+    @Override
+    public Collection<Class<? extends ThingHandlerService>> getServices() {
+        return Set.of(NetatmoDiscoveryService.class);
+    }
+
+    public Optional<WebhookServlet> getWebHookServlet() {
+        return Optional.ofNullable(webHookServlet);
     }
 }
index aa519e21bbee62498928a7fd7e77e09b104332c5..adec7694db92ac5b7f93d7b1c7d4fa627013c92b 100644 (file)
@@ -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<Channel> getActiveChannels() {
index a72abc4066508f0d7426fc7711c3bd7b40acf93d..aa4e294981d1747b61ec4f908100912d4816d2c4 100644 (file)
@@ -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<NetatmoServlet> servlet = Optional.empty();
+    private Optional<WebhookServlet> 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()));
     }
 }
index 0e81eb687e32cd7d4b512296f1635c4a7d4f4537..7f5608beb3c219517acf86aac3fcd05bb3565c41 100644 (file)
@@ -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 (file)
index 0000000..f5574b1
--- /dev/null
@@ -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 = "<p class='block error'>Call to Netatmo Connect failed with error: %s</p>";
+
+    // 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<String, String> 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<String, String> 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<String, String> map) {
+        final Matcher m = MESSAGE_KEY_PATTERN.matcher(template);
+        final StringBuffer sb = new StringBuffer();
+
+        while (m.find()) {
+            try {
+                final String key = m.group(1);
+                m.appendReplacement(sb, Matcher.quoteReplacement(map.getOrDefault(key, "${" + key + '}')));
+            } catch (RuntimeException e) {
+                logger.debug("Error occurred during template filling, cause ", e);
+            }
+        }
+        m.appendTail(sb);
+        return sb.toString();
+    }
+}
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 (file)
index 0000000..458aa38
--- /dev/null
@@ -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 (file)
index 0000000..378081e
--- /dev/null
@@ -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<String, EventCapability> 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<String> 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<String> 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 (file)
index ffd091a..0000000
+++ /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<String, EventCapability> dataListeners = new ConcurrentHashMap<>();
-    private final HttpService httpService;
-    private final NADeserializer deserializer;
-    private final Optional<SecurityApi> 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<String> 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<String> collectNotified(WebhookEvent event) {
-        List<String> 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;
-    }
-}
index e7023f6ce00d6732f6d5622305ac38e6b4c27a83..6be633ac237998f9353b7b404739f685b8308558 100644 (file)
                        <context>password</context>
                </parameter>
 
-               <parameter name="username" type="text" required="true">
-                       <label>Username</label>
-                       <description>Your Netatmo API username (email).</description>
-               </parameter>
-
-               <parameter name="password" type="text" required="true">
-                       <label>Password</label>
-                       <description>Your Netatmo API password.</description>
+               <parameter name="refreshToken" type="text">
+                       <label>Refresh Token</label>
+                       <description>Refresh token provided by the oAuth2 authentication process.</description>
                        <context>password</context>
+                       <advanced>true</advanced>
                </parameter>
 
                <parameter name="webHookUrl" type="text" required="false">
                        <label>Webhook Address</label>
-                       <description>Protocol, public IP and port to access openHAB server from Internet.</description>
+                       <description>Protocol, public IP or hostname and port to access openHAB server from Internet.</description>
                </parameter>
 
                <parameter name="reconnectInterval" type="integer" unit="s">
index cc52514f7d4e3cd32ab79931cf5a0b287b95b6e4..2d6165441417448f13be6c3ce2abfaba60b7b7cd 100644 (file)
@@ -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 (file)
index 0000000..af00094
--- /dev/null
@@ -0,0 +1,74 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+<title>Authorize openHAB Bridge at Netatmo Connect</title>
+<link>
+<style>
+html {
+    font-family: "Roboto", Helvetica, Arial, sans-serif;
+}
+
+.logo {
+    display: block;
+    margin: auto;
+    width: 100%;
+}
+
+.block {
+    border: 1px solid #bbb;
+    background-color: white;
+    margin: 10px 0;
+    padding: 8px 10px;
+}
+
+.error {
+    background: #FFC0C0;
+    border: 1px solid darkred;
+    color: darkred
+}
+
+.authorized {
+    border: 1px solid #90EE90;
+    background-color: #E0FFE0;
+}
+
+.button {
+    margin-bottom: 10px;
+}
+
+.button a {
+    background: #1ED760;
+    border-radius: 500px;
+    color: white;
+    padding: 10px 20px 10px;
+    font-size: 16px;
+    font-weight: 700;
+    border-width: 0;
+    text-decoration: none;
+}
+</style>
+</head>
+<body>
+    <h3>Authorize openHAB Bridge at Netatmo Connect</h3>
+    <p>On this page you can authorize your openHAB Netatmo Bridge configured with the clientId and clientSecret of the Netatmo Application on your Developer account.</p>
+    <p>You have to login to your Netatmo Account and authorize this binding to access your account.</p>
+    <p>To use this binding the following requirements apply:</p>
+    <ul>
+        <li>A Netatmo connect account.
+        <li>Register openHAB as an App on your Netatmo Connect account.
+    </ul>
+    <p>
+        The redirect URI to use with Netatmo for this openHAB Netatmo Bridge is
+        <a href="${redirect_uri}">${redirect_uri}</a>
+    </p>
+    ${error} 
+       <div class="block${account.authorized}" id="${client_id}">
+         Connect to Netatmo: <i>${account.name}</i>
+         <p><div class="button"><a href=${account.authorize}>Authorize Thing</a></div></p>
+       </div>
+</body>
+</html>
+
+