[netatmo] Switch to Code Granting process (#12726)

Signed-off-by: clinique <gael@lhopital.org>
This commit is contained in:
Gaël L'hopital 2022-05-20 12:53:53 +02:00 committed by GitHub
parent c12ed4bc8e
commit 9632a0a870
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 715 additions and 333 deletions

View 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:
- **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://<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).
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:

View 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";

View File

@ -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<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);

View 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);
}
}

View 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);
}
}

View 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 {

View 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;

View 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;
}
}

View File

@ -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;
}
}

View 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;
}

View 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();
}
}

View 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 Optional<NetatmoServlet> 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<Class<? extends ThingHandlerService>> getServices() {
return Set.of(NetatmoDiscoveryService.class);
}
public Optional<WebhookServlet> getWebHookServlet() {
return Optional.ofNullable(webHookServlet);
}
}

View 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() {

View 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()));
}
}

View 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());

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -17,20 +17,16 @@
<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">

View 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

View File

@ -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>