2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.netatmo.internal.handler;
15 import static java.util.Comparator.*;
16 import static org.openhab.binding.netatmo.internal.NetatmoBindingConstants.*;
18 import java.io.ByteArrayInputStream;
19 import java.io.IOException;
20 import java.io.InputStream;
21 import java.lang.reflect.Constructor;
23 import java.nio.charset.StandardCharsets;
24 import java.time.LocalDateTime;
25 import java.util.ArrayDeque;
26 import java.util.Collection;
27 import java.util.Deque;
28 import java.util.HashMap;
30 import java.util.Objects;
31 import java.util.Optional;
33 import java.util.concurrent.ExecutionException;
34 import java.util.concurrent.ScheduledFuture;
35 import java.util.concurrent.TimeUnit;
36 import java.util.concurrent.TimeoutException;
37 import java.util.function.BiFunction;
39 import javax.ws.rs.core.UriBuilder;
41 import org.eclipse.jdt.annotation.NonNullByDefault;
42 import org.eclipse.jdt.annotation.Nullable;
43 import org.eclipse.jetty.client.HttpClient;
44 import org.eclipse.jetty.client.api.ContentResponse;
45 import org.eclipse.jetty.client.api.Request;
46 import org.eclipse.jetty.client.util.InputStreamContentProvider;
47 import org.eclipse.jetty.http.HttpField;
48 import org.eclipse.jetty.http.HttpHeader;
49 import org.eclipse.jetty.http.HttpMethod;
50 import org.eclipse.jetty.http.HttpStatus;
51 import org.eclipse.jetty.http.HttpStatus.Code;
52 import org.openhab.binding.netatmo.internal.api.AircareApi;
53 import org.openhab.binding.netatmo.internal.api.ApiError;
54 import org.openhab.binding.netatmo.internal.api.AuthenticationApi;
55 import org.openhab.binding.netatmo.internal.api.HomeApi;
56 import org.openhab.binding.netatmo.internal.api.ListBodyResponse;
57 import org.openhab.binding.netatmo.internal.api.NetatmoException;
58 import org.openhab.binding.netatmo.internal.api.RestManager;
59 import org.openhab.binding.netatmo.internal.api.SecurityApi;
60 import org.openhab.binding.netatmo.internal.api.WeatherApi;
61 import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.FeatureArea;
62 import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.Scope;
63 import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.ServiceError;
64 import org.openhab.binding.netatmo.internal.api.dto.HomeDataModule;
65 import org.openhab.binding.netatmo.internal.api.dto.NAMain;
66 import org.openhab.binding.netatmo.internal.api.dto.NAModule;
67 import org.openhab.binding.netatmo.internal.config.ApiHandlerConfiguration;
68 import org.openhab.binding.netatmo.internal.config.BindingConfiguration;
69 import org.openhab.binding.netatmo.internal.config.ConfigurationLevel;
70 import org.openhab.binding.netatmo.internal.deserialization.AccessTokenResponseDeserializer;
71 import org.openhab.binding.netatmo.internal.deserialization.NADeserializer;
72 import org.openhab.binding.netatmo.internal.discovery.NetatmoDiscoveryService;
73 import org.openhab.binding.netatmo.internal.servlet.GrantServlet;
74 import org.openhab.binding.netatmo.internal.servlet.WebhookServlet;
75 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
76 import org.openhab.core.auth.client.oauth2.OAuthClientService;
77 import org.openhab.core.auth.client.oauth2.OAuthException;
78 import org.openhab.core.auth.client.oauth2.OAuthFactory;
79 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
80 import org.openhab.core.library.types.DecimalType;
81 import org.openhab.core.thing.Bridge;
82 import org.openhab.core.thing.ChannelUID;
83 import org.openhab.core.thing.Thing;
84 import org.openhab.core.thing.ThingStatus;
85 import org.openhab.core.thing.ThingStatusDetail;
86 import org.openhab.core.thing.ThingUID;
87 import org.openhab.core.thing.binding.BaseBridgeHandler;
88 import org.openhab.core.thing.binding.ThingHandlerService;
89 import org.openhab.core.types.Command;
90 import org.osgi.service.http.HttpService;
91 import org.slf4j.Logger;
92 import org.slf4j.LoggerFactory;
94 import com.google.gson.GsonBuilder;
97 * {@link ApiBridgeHandler} is the handler for a Netatmo API and connects it to the framework.
99 * @author Gaƫl L'hopital - Initial contribution
100 * @author Jacob Laursen - Refactored to use standard OAuth2 implementation
103 public class ApiBridgeHandler extends BaseBridgeHandler {
104 private static final int TIMEOUT_S = 20;
106 private final Logger logger = LoggerFactory.getLogger(ApiBridgeHandler.class);
107 private final AuthenticationApi connectApi = new AuthenticationApi(this);
108 private final Map<Class<? extends RestManager>, RestManager> managers = new HashMap<>();
109 private final Deque<LocalDateTime> requestsTimestamps = new ArrayDeque<>(200);
110 private final BindingConfiguration bindingConf;
111 private final HttpClient httpClient;
112 private final OAuthFactory oAuthFactory;
113 private final NADeserializer deserializer;
114 private final HttpService httpService;
115 private final ChannelUID requestCountChannelUID;
117 private @Nullable OAuthClientService oAuthClientService;
118 private Optional<ScheduledFuture<?>> connectJob = Optional.empty();
119 private Optional<WebhookServlet> webHookServlet = Optional.empty();
120 private Optional<GrantServlet> grantServlet = Optional.empty();
122 public ApiBridgeHandler(Bridge bridge, HttpClient httpClient, NADeserializer deserializer,
123 BindingConfiguration configuration, HttpService httpService, OAuthFactory oAuthFactory) {
125 this.bindingConf = configuration;
126 this.httpClient = httpClient;
127 this.deserializer = deserializer;
128 this.httpService = httpService;
129 this.oAuthFactory = oAuthFactory;
131 requestCountChannelUID = new ChannelUID(thing.getUID(), GROUP_MONITORING, CHANNEL_REQUEST_COUNT);
135 public void initialize() {
136 logger.debug("Initializing Netatmo API bridge handler.");
138 ApiHandlerConfiguration configuration = getConfiguration();
140 if (configuration.clientId.isBlank()) {
141 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
142 ConfigurationLevel.EMPTY_CLIENT_ID.message);
146 if (configuration.clientSecret.isBlank()) {
147 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
148 ConfigurationLevel.EMPTY_CLIENT_SECRET.message);
152 oAuthClientService = oAuthFactory
153 .createOAuthClientService(this.getThing().getUID().getAsString(),
154 AuthenticationApi.TOKEN_URI.toString(), AuthenticationApi.AUTH_URI.toString(),
155 configuration.clientId, configuration.clientSecret, FeatureArea.ALL_SCOPES, false)
156 .withGsonBuilder(new GsonBuilder().registerTypeAdapter(AccessTokenResponse.class,
157 new AccessTokenResponseDeserializer()));
159 updateStatus(ThingStatus.UNKNOWN);
161 scheduler.execute(() -> openConnection(null, null));
164 public void openConnection(@Nullable String code, @Nullable String redirectUri) {
165 if (!authenticate(code, redirectUri)) {
169 logger.debug("Connecting to Netatmo API.");
171 ApiHandlerConfiguration configuration = getConfiguration();
172 if (!configuration.webHookUrl.isBlank()) {
173 SecurityApi securityApi = getRestManager(SecurityApi.class);
174 if (securityApi != null) {
175 webHookServlet.ifPresent(servlet -> servlet.dispose());
176 WebhookServlet servlet = new WebhookServlet(this, httpService, deserializer, securityApi,
177 configuration.webHookUrl, configuration.webHookPostfix);
178 servlet.startListening();
179 this.webHookServlet = Optional.of(servlet);
183 updateStatus(ThingStatus.ONLINE);
185 getThing().getThings().stream().filter(Thing::isEnabled).map(Thing::getHandler).filter(Objects::nonNull)
186 .map(CommonInterface.class::cast).forEach(CommonInterface::expireData);
189 private boolean authenticate(@Nullable String code, @Nullable String redirectUri) {
190 OAuthClientService oAuthClientService = this.oAuthClientService;
191 if (oAuthClientService == null) {
192 logger.debug("ApiBridgeHandler is not ready, OAuthClientService not initialized");
196 AccessTokenResponse accessTokenResponse;
199 accessTokenResponse = oAuthClientService.getAccessTokenResponseByAuthorizationCode(code, redirectUri);
201 // Dispose grant servlet upon completion of authorization flow.
202 grantServlet.ifPresent(servlet -> servlet.dispose());
203 grantServlet = Optional.empty();
205 accessTokenResponse = oAuthClientService.getAccessTokenResponse();
207 } catch (OAuthException | OAuthResponseException e) {
208 logger.debug("Failed to load access token: {}", e.getMessage());
209 startAuthorizationFlow();
211 } catch (IOException e) {
212 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
213 prepareReconnection(code, redirectUri);
217 if (accessTokenResponse == null) {
218 logger.debug("Authorization failed, restarting authorization flow");
219 startAuthorizationFlow();
223 connectApi.setAccessToken(accessTokenResponse.getAccessToken());
224 connectApi.setScope(accessTokenResponse.getScope());
229 private void startAuthorizationFlow() {
230 GrantServlet servlet = new GrantServlet(this, httpService);
231 servlet.startListening();
232 grantServlet = Optional.of(servlet);
233 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
234 ConfigurationLevel.REFRESH_TOKEN_NEEDED.message);
237 public ApiHandlerConfiguration getConfiguration() {
238 return getConfigAs(ApiHandlerConfiguration.class);
241 private void prepareReconnection(@Nullable String code, @Nullable String redirectUri) {
242 connectApi.dispose();
244 connectJob = Optional.of(scheduler.schedule(() -> openConnection(code, redirectUri),
245 getConfiguration().reconnectInterval, TimeUnit.SECONDS));
248 private void freeConnectJob() {
249 connectJob.ifPresent(j -> j.cancel(true));
250 connectJob = Optional.empty();
254 public void dispose() {
255 logger.debug("Shutting down Netatmo API bridge handler.");
257 webHookServlet.ifPresent(servlet -> servlet.dispose());
258 webHookServlet = Optional.empty();
260 grantServlet.ifPresent(servlet -> servlet.dispose());
261 grantServlet = Optional.empty();
263 connectApi.dispose();
266 oAuthFactory.ungetOAuthService(this.getThing().getUID().getAsString());
272 public void handleRemoval() {
273 oAuthFactory.deleteServiceAndAccessToken(this.getThing().getUID().getAsString());
274 super.handleRemoval();
278 public void handleCommand(ChannelUID channelUID, Command command) {
279 logger.debug("Netatmo Bridge is read-only and does not handle commands");
282 @SuppressWarnings("unchecked")
283 public <T extends RestManager> @Nullable T getRestManager(Class<T> clazz) {
284 if (!managers.containsKey(clazz)) {
286 Constructor<T> constructor = clazz.getConstructor(getClass());
287 T instance = constructor.newInstance(this);
288 Set<Scope> expected = instance.getRequiredScopes();
289 if (connectApi.matchesScopes(expected)) {
290 managers.put(clazz, instance);
292 logger.info("Unable to instantiate {}, expected scope {} is not active", clazz, expected);
294 } catch (SecurityException | ReflectiveOperationException e) {
295 logger.warn("Error invoking RestManager constructor for class {} : {}", clazz, e.getMessage());
298 return (T) managers.get(clazz);
301 public synchronized <T> T executeUri(URI uri, HttpMethod method, Class<T> clazz, @Nullable String payload,
302 @Nullable String contentType, int retryCount) throws NetatmoException {
304 logger.trace("executeUri {} {} ", method.toString(), uri);
306 Request request = httpClient.newRequest(uri).method(method).timeout(TIMEOUT_S, TimeUnit.SECONDS);
308 if (!authenticate(null, null)) {
309 prepareReconnection(null, null);
310 throw new NetatmoException("Not authenticated");
312 connectApi.getAuthorization().ifPresent(auth -> request.header(HttpHeader.AUTHORIZATION, auth));
314 if (payload != null && contentType != null
315 && (HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method))) {
316 InputStream stream = new ByteArrayInputStream(payload.getBytes(StandardCharsets.UTF_8));
317 try (InputStreamContentProvider inputStreamContentProvider = new InputStreamContentProvider(stream)) {
318 request.content(inputStreamContentProvider, contentType);
320 logger.trace(" -with payload : {} ", payload);
323 if (isLinked(requestCountChannelUID)) {
324 LocalDateTime now = LocalDateTime.now();
325 LocalDateTime oneHourAgo = now.minusHours(1);
326 requestsTimestamps.addLast(now);
327 while (requestsTimestamps.getFirst().isBefore(oneHourAgo)) {
328 requestsTimestamps.removeFirst();
330 updateState(requestCountChannelUID, new DecimalType(requestsTimestamps.size()));
332 logger.trace(" -with headers : {} ",
333 String.join(", ", request.getHeaders().stream().map(HttpField::toString).toList()));
334 ContentResponse response = request.send();
336 Code statusCode = HttpStatus.getCode(response.getStatus());
337 String responseBody = new String(response.getContent(), StandardCharsets.UTF_8);
338 logger.trace(" -returned : code {} body {}", statusCode, responseBody);
340 if (statusCode == Code.OK) {
341 return deserializer.deserialize(clazz, responseBody);
344 NetatmoException exception;
346 exception = new NetatmoException(deserializer.deserialize(ApiError.class, responseBody));
347 } catch (NetatmoException e) {
348 exception = new NetatmoException("Error deserializing error : %s".formatted(statusCode.getMessage()));
351 } catch (NetatmoException e) {
352 if (e.getStatusCode() == ServiceError.MAXIMUM_USAGE_REACHED) {
353 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
354 prepareReconnection(null, null);
357 } catch (InterruptedException e) {
358 Thread.currentThread().interrupt();
359 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
360 throw new NetatmoException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
361 } catch (TimeoutException | ExecutionException e) {
362 if (retryCount > 0) {
363 logger.debug("Request timedout, retry counter : {}", retryCount);
364 return executeUri(uri, method, clazz, payload, contentType, retryCount - 1);
366 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/request-time-out");
367 prepareReconnection(null, null);
368 throw new NetatmoException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
372 public void identifyAllModulesAndApplyAction(BiFunction<NAModule, ThingUID, Optional<ThingUID>> action) {
373 ThingUID accountUID = getThing().getUID();
375 AircareApi airCareApi = getRestManager(AircareApi.class);
376 if (airCareApi != null) { // Search Healthy Home Coaches
377 ListBodyResponse<NAMain> body = airCareApi.getHomeCoachData(null).getBody();
379 body.getElements().stream().forEach(homeCoach -> action.apply(homeCoach, accountUID));
382 WeatherApi weatherApi = getRestManager(WeatherApi.class);
383 if (weatherApi != null) { // Search owned or favorite stations
384 weatherApi.getFavoriteAndGuestStationsData().stream().forEach(station -> {
385 if (!station.isReadOnly() || getReadFriends()) {
386 action.apply(station, accountUID).ifPresent(stationUID -> station.getModules().values().stream()
387 .forEach(module -> action.apply(module, stationUID)));
391 HomeApi homeApi = getRestManager(HomeApi.class);
392 if (homeApi != null) { // Search those depending from a home that has modules + not only weather modules
393 homeApi.getHomesData(null, null).stream()
394 .filter(h -> !(h.getFeatures().isEmpty()
395 || h.getFeatures().contains(FeatureArea.WEATHER) && h.getFeatures().size() == 1))
397 action.apply(home, accountUID).ifPresent(homeUID -> {
398 home.getKnownPersons().forEach(person -> action.apply(person, homeUID));
400 Map<String, ThingUID> bridgesUids = new HashMap<>();
402 home.getRooms().values().stream().forEach(room -> {
403 room.getModuleIds().stream().map(id -> home.getModules().get(id))
404 .map(m -> m != null ? m.getType().feature : FeatureArea.NONE)
405 .filter(f -> FeatureArea.ENERGY.equals(f)).findAny().ifPresent(f -> {
406 action.apply(room, homeUID)
407 .ifPresent(roomUID -> bridgesUids.put(room.getId(), roomUID));
411 // Creating modules that have no bridge first, avoiding weather station itself
412 home.getModules().values().stream()
413 .filter(module -> module.getType().feature != FeatureArea.WEATHER)
414 .sorted(comparing(HomeDataModule::getBridge, nullsFirst(naturalOrder())))
416 String bridgeId = module.getBridge();
417 if (bridgeId == null) {
418 action.apply(module, homeUID).ifPresent(
419 moduleUID -> bridgesUids.put(module.getId(), moduleUID));
421 action.apply(module, bridgesUids.getOrDefault(bridgeId, homeUID));
427 } catch (NetatmoException e) {
428 logger.warn("Error while identifying all modules : {}", e.getMessage());
432 public boolean getReadFriends() {
433 return bindingConf.readFriends;
436 public boolean isConnected() {
437 return connectApi.isConnected();
440 public String getId() {
441 return (String) getThing().getConfiguration().get(ApiHandlerConfiguration.CLIENT_ID);
444 public UriBuilder formatAuthorizationUrl() {
445 return AuthenticationApi.getAuthorizationBuilder(getId());
449 public Collection<Class<? extends ThingHandlerService>> getServices() {
450 return Set.of(NetatmoDiscoveryService.class);
453 public Optional<WebhookServlet> getWebHookServlet() {
454 return webHookServlet;