## 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:
| 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
### 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:
```
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.
## 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:
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";
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 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);
}
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);
*/
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;
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;
*/
@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() {
}
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() {
}
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);
}
}
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);
}
}
* @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 {
// 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";
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;
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
*/
@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;
}
}
--- /dev/null
+/**
+ * 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;
+ }
+}
*/
@NonNullByDefault
public class NAThingConfiguration {
+ public static final String ID = "id";
+
public String id = "";
public int refreshInterval = -1;
}
*/
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;
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;
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)
@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);
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();
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);
public void setThingHandler(ThingHandler handler) {
if (handler instanceof ApiBridgeHandler) {
this.handler = (ApiBridgeHandler) handler;
- this.config = ((ApiBridgeHandler) handler).getConfiguration();
+ this.readFriends = ((ApiBridgeHandler) handler).getReadFriends();
}
}
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;
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;
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() {
@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();
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)) {
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);
}
}
}
default String getId() {
- return (String) getThing().getConfiguration().get("id");
+ return (String) getThing().getConfiguration().get(NAThingConfiguration.ID);
}
default Stream<Channel> getActiveChannels() {
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);
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()));
}
}
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;
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());
--- /dev/null
+/**
+ * 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();
+ }
+}
--- /dev/null
+/**
+ * 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();
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+}
+++ /dev/null
-/**
- * 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;
- }
-}
<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">
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
--- /dev/null
+<!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>
+
+