Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[netatmo] Switch to Code Granting process #12726

Merged
merged 7 commits into from
May 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 36 additions & 17 deletions bundles/org.openhab.binding.netatmo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
lolodomo marked this conversation as resolved.
Show resolved Hide resolved

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:

Expand All @@ -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).
lolodomo marked this conversation as resolved.
Show resolved Hide resolved

- **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.
lolodomo marked this conversation as resolved.
Show resolved Hide resolved


## List of supported things
Expand Down Expand Up @@ -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:

```
Expand All @@ -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_>
lolodomo marked this conversation as resolved.
Show resolved Hide resolved
```

Please be aware of Netatmo own limits regarding webhook usage that lead to a 24h ban-time when webhook does not answer 5 times.
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,20 +68,20 @@
public class NetatmoHandlerFactory extends BaseThingHandlerFactory {
private final Logger logger = LoggerFactory.getLogger(NetatmoHandlerFactory.class);

private final BindingConfiguration configuration = new BindingConfiguration();
private final NetatmoDescriptionProvider stateDescriptionProvider;
private final HttpClient httpClient;
private final NADeserializer deserializer;
private final HttpClient httpClient;
private final HttpService httpService;
private final BindingConfiguration configuration = new BindingConfiguration();

@Activate
public NetatmoHandlerFactory(@Reference NetatmoDescriptionProvider stateDescriptionProvider,
@Reference HttpClientFactory factory, @Reference NADeserializer deserializer,
@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);
}

Expand All @@ -107,7 +107,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) {

private BaseThingHandler buildHandler(Thing thing, ModuleType moduleType) {
if (ModuleType.ACCOUNT.equals(moduleType)) {
return new ApiBridgeHandler((Bridge) thing, httpClient, httpService, deserializer, configuration);
return new ApiBridgeHandler((Bridge) thing, httpClient, deserializer, configuration, httpService);
}
CommonInterface handler = moduleType.isABridge() ? new DeviceHandler((Bridge) thing) : new ModuleHandler(thing);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,12 +24,14 @@
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;

import javax.ws.rs.core.UriBuilder;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.FeatureArea;
import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.Scope;
import org.openhab.binding.netatmo.internal.api.dto.AccessTokenResponse;
import org.openhab.binding.netatmo.internal.config.ApiHandlerConfiguration.Credentials;
import org.openhab.binding.netatmo.internal.config.ApiHandlerConfiguration;
import org.openhab.binding.netatmo.internal.handler.ApiBridgeHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -41,66 +43,87 @@
*/
@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() {
tokenResponse = Optional.empty();
}

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() {
return tokenResponse.map(at -> String.format("Bearer %s", at.getAccessToken())).orElse(null);
}

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public ServiceError getStatusCode() {
public @Nullable String getMessage() {
String message = super.getMessage();
return message == null ? null
: String.format("Rest call failed: statusCode=%s, message=%s", statusCode, message);
: ServiceError.UNKNOWN.equals(statusCode) ? message
: String.format("Rest call failed: statusCode=%s, message=%s", statusCode, message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ public void dropWebhook() throws NetatmoException {
* @param uri Your webhook callback url (required)
* @throws NetatmoException If fail to call the API, e.g. server error or deserializing
*/
public void addwebhook(URI uri) throws NetatmoException {
public boolean addwebhook(URI uri) throws NetatmoException {
UriBuilder uriBuilder = getApiUriBuilder(SUB_PATH_ADDWEBHOOK, PARAM_URL, uri.toString());
post(uriBuilder, ApiResponse.Ok.class, null, null);
return true;
}

public Collection<HomeEvent> getPersonEvents(String homeId, String personId) throws NetatmoException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ public enum MeasureClass {
// Netatmo API urls
public static final String URL_API = "https://api.netatmo.com/";
public static final String URL_APP = "https://app.netatmo.net/";
public static final String PATH_OAUTH = "oauth2/token";
public static final String PATH_OAUTH = "oauth2";
public static final String SUB_PATH_TOKEN = "token";
public static final String SUB_PATH_AUTHORIZE = "authorize";
public static final String PATH_API = "api";
public static final String PATH_COMMAND = "command";
public static final String SUB_PATH_PERSON_AWAY = "setpersonsaway";
Expand Down Expand Up @@ -148,6 +150,9 @@ public enum MeasureClass {
public static final String PARAM_FAVORITES = "get_favorites";
public static final String PARAM_STATUS = "status";

// Autentication process params
public static final String PARAM_ERROR = "error";

// Global variables
public static final int THERM_MAX_SETPOINT = 30;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,39 +22,23 @@
*/
@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;
}
}
Loading