]> git.basschouten.com Git - openhab-addons.git/blob
cec105c56712e823ab3b2f86100dfb419a4d6679
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.netatmo.internal.handler;
14
15 import static java.util.Comparator.*;
16 import static org.openhab.binding.netatmo.internal.NetatmoBindingConstants.*;
17
18 import java.io.ByteArrayInputStream;
19 import java.io.IOException;
20 import java.io.InputStream;
21 import java.lang.reflect.Constructor;
22 import java.net.URI;
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;
29 import java.util.Map;
30 import java.util.Optional;
31 import java.util.Set;
32 import java.util.concurrent.ExecutionException;
33 import java.util.concurrent.ScheduledFuture;
34 import java.util.concurrent.TimeUnit;
35 import java.util.concurrent.TimeoutException;
36 import java.util.function.BiFunction;
37
38 import javax.ws.rs.core.UriBuilder;
39
40 import org.eclipse.jdt.annotation.NonNullByDefault;
41 import org.eclipse.jdt.annotation.Nullable;
42 import org.eclipse.jetty.client.HttpClient;
43 import org.eclipse.jetty.client.api.ContentResponse;
44 import org.eclipse.jetty.client.api.Request;
45 import org.eclipse.jetty.client.util.InputStreamContentProvider;
46 import org.eclipse.jetty.http.HttpField;
47 import org.eclipse.jetty.http.HttpHeader;
48 import org.eclipse.jetty.http.HttpMethod;
49 import org.eclipse.jetty.http.HttpStatus;
50 import org.eclipse.jetty.http.HttpStatus.Code;
51 import org.openhab.binding.netatmo.internal.api.AircareApi;
52 import org.openhab.binding.netatmo.internal.api.ApiError;
53 import org.openhab.binding.netatmo.internal.api.AuthenticationApi;
54 import org.openhab.binding.netatmo.internal.api.HomeApi;
55 import org.openhab.binding.netatmo.internal.api.ListBodyResponse;
56 import org.openhab.binding.netatmo.internal.api.NetatmoException;
57 import org.openhab.binding.netatmo.internal.api.RestManager;
58 import org.openhab.binding.netatmo.internal.api.SecurityApi;
59 import org.openhab.binding.netatmo.internal.api.WeatherApi;
60 import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.FeatureArea;
61 import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.Scope;
62 import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.ServiceError;
63 import org.openhab.binding.netatmo.internal.api.dto.HomeData;
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;
93
94 import com.google.gson.GsonBuilder;
95
96 /**
97  * {@link ApiBridgeHandler} is the handler for a Netatmo API and connects it to the framework.
98  *
99  * @author GaĆ«l L'hopital - Initial contribution
100  * @author Jacob Laursen - Refactored to use standard OAuth2 implementation
101  */
102 @NonNullByDefault
103 public class ApiBridgeHandler extends BaseBridgeHandler {
104     private static final int TIMEOUT_S = 20;
105
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;
116
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();
121
122     public ApiBridgeHandler(Bridge bridge, HttpClient httpClient, NADeserializer deserializer,
123             BindingConfiguration configuration, HttpService httpService, OAuthFactory oAuthFactory) {
124         super(bridge);
125         this.bindingConf = configuration;
126         this.httpClient = httpClient;
127         this.deserializer = deserializer;
128         this.httpService = httpService;
129         this.oAuthFactory = oAuthFactory;
130
131         requestCountChannelUID = new ChannelUID(thing.getUID(), GROUP_MONITORING, CHANNEL_REQUEST_COUNT);
132     }
133
134     @Override
135     public void initialize() {
136         logger.debug("Initializing Netatmo API bridge handler.");
137
138         ApiHandlerConfiguration configuration = getConfiguration();
139
140         if (configuration.clientId.isBlank()) {
141             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
142                     ConfigurationLevel.EMPTY_CLIENT_ID.message);
143             return;
144         }
145
146         if (configuration.clientSecret.isBlank()) {
147             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
148                     ConfigurationLevel.EMPTY_CLIENT_SECRET.message);
149             return;
150         }
151
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()));
158
159         updateStatus(ThingStatus.UNKNOWN);
160
161         scheduler.execute(() -> openConnection(null, null));
162     }
163
164     public void openConnection(@Nullable String code, @Nullable String redirectUri) {
165         if (!authenticate(code, redirectUri)) {
166             return;
167         }
168
169         logger.debug("Connecting to Netatmo API.");
170
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);
180             }
181         }
182
183         updateStatus(ThingStatus.ONLINE);
184
185         getThing().getThings().stream().filter(Thing::isEnabled).map(Thing::getHandler)
186                 .filter(CommonInterface.class::isInstance).map(CommonInterface.class::cast)
187                 .forEach(CommonInterface::expireData);
188     }
189
190     private boolean authenticate(@Nullable String code, @Nullable String redirectUri) {
191         OAuthClientService oAuthClientService = this.oAuthClientService;
192         if (oAuthClientService == null) {
193             logger.debug("ApiBridgeHandler is not ready, OAuthClientService not initialized");
194             return false;
195         }
196
197         AccessTokenResponse accessTokenResponse;
198         try {
199             if (code != null) {
200                 accessTokenResponse = oAuthClientService.getAccessTokenResponseByAuthorizationCode(code, redirectUri);
201
202                 // Dispose grant servlet upon completion of authorization flow.
203                 grantServlet.ifPresent(servlet -> servlet.dispose());
204                 grantServlet = Optional.empty();
205             } else {
206                 accessTokenResponse = oAuthClientService.getAccessTokenResponse();
207             }
208         } catch (OAuthException | OAuthResponseException e) {
209             logger.debug("Failed to load access token: {}", e.getMessage());
210             startAuthorizationFlow();
211             return false;
212         } catch (IOException e) {
213             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
214             prepareReconnection(code, redirectUri);
215             return false;
216         }
217
218         if (accessTokenResponse == null) {
219             logger.debug("Authorization failed, restarting authorization flow");
220             startAuthorizationFlow();
221             return false;
222         }
223
224         connectApi.setAccessToken(accessTokenResponse.getAccessToken());
225         connectApi.setScope(accessTokenResponse.getScope());
226
227         return true;
228     }
229
230     private void startAuthorizationFlow() {
231         GrantServlet servlet = new GrantServlet(this, httpService);
232         servlet.startListening();
233         grantServlet = Optional.of(servlet);
234         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
235                 ConfigurationLevel.REFRESH_TOKEN_NEEDED.message.formatted(servlet.getPath()));
236     }
237
238     public ApiHandlerConfiguration getConfiguration() {
239         return getConfigAs(ApiHandlerConfiguration.class);
240     }
241
242     private void prepareReconnection(@Nullable String code, @Nullable String redirectUri) {
243         connectApi.dispose();
244         freeConnectJob();
245         connectJob = Optional.of(scheduler.schedule(() -> openConnection(code, redirectUri),
246                 getConfiguration().reconnectInterval, TimeUnit.SECONDS));
247     }
248
249     private void freeConnectJob() {
250         connectJob.ifPresent(j -> j.cancel(true));
251         connectJob = Optional.empty();
252     }
253
254     @Override
255     public void dispose() {
256         logger.debug("Shutting down Netatmo API bridge handler.");
257
258         webHookServlet.ifPresent(servlet -> servlet.dispose());
259         webHookServlet = Optional.empty();
260
261         grantServlet.ifPresent(servlet -> servlet.dispose());
262         grantServlet = Optional.empty();
263
264         connectApi.dispose();
265         freeConnectJob();
266
267         oAuthFactory.ungetOAuthService(this.getThing().getUID().getAsString());
268
269         super.dispose();
270     }
271
272     @Override
273     public void handleRemoval() {
274         oAuthFactory.deleteServiceAndAccessToken(this.getThing().getUID().getAsString());
275         super.handleRemoval();
276     }
277
278     @Override
279     public void handleCommand(ChannelUID channelUID, Command command) {
280         logger.debug("Netatmo Bridge is read-only and does not handle commands");
281     }
282
283     @SuppressWarnings("unchecked")
284     public <T extends RestManager> @Nullable T getRestManager(Class<T> clazz) {
285         if (!managers.containsKey(clazz)) {
286             try {
287                 Constructor<T> constructor = clazz.getConstructor(getClass());
288                 T instance = constructor.newInstance(this);
289                 Set<Scope> expected = instance.getRequiredScopes();
290                 if (connectApi.matchesScopes(expected)) {
291                     managers.put(clazz, instance);
292                 } else {
293                     logger.info("Unable to instantiate {}, expected scope {} is not active", clazz, expected);
294                 }
295             } catch (SecurityException | ReflectiveOperationException e) {
296                 logger.warn("Error invoking RestManager constructor for class {}: {}", clazz, e.getMessage());
297             }
298         }
299         return (T) managers.get(clazz);
300     }
301
302     public synchronized <T> T executeUri(URI uri, HttpMethod method, Class<T> clazz, @Nullable String payload,
303             @Nullable String contentType, int retryCount) throws NetatmoException {
304         try {
305             logger.debug("executeUri {}  {} ", method.toString(), uri);
306
307             Request request = httpClient.newRequest(uri).method(method).timeout(TIMEOUT_S, TimeUnit.SECONDS);
308
309             if (!authenticate(null, null)) {
310                 prepareReconnection(null, null);
311                 throw new NetatmoException("Not authenticated");
312             }
313             connectApi.getAuthorization().ifPresent(auth -> request.header(HttpHeader.AUTHORIZATION, auth));
314
315             if (payload != null && contentType != null
316                     && (HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method))) {
317                 InputStream stream = new ByteArrayInputStream(payload.getBytes(StandardCharsets.UTF_8));
318                 try (InputStreamContentProvider inputStreamContentProvider = new InputStreamContentProvider(stream)) {
319                     request.content(inputStreamContentProvider, contentType);
320                     request.header(HttpHeader.ACCEPT, "application/json");
321                 }
322                 logger.trace(" -with payload: {} ", payload);
323             }
324
325             if (isLinked(requestCountChannelUID)) {
326                 LocalDateTime now = LocalDateTime.now();
327                 LocalDateTime oneHourAgo = now.minusHours(1);
328                 requestsTimestamps.addLast(now);
329                 while (requestsTimestamps.getFirst().isBefore(oneHourAgo)) {
330                     requestsTimestamps.removeFirst();
331                 }
332                 updateState(requestCountChannelUID, new DecimalType(requestsTimestamps.size()));
333             }
334             logger.trace(" -with headers: {} ",
335                     String.join(", ", request.getHeaders().stream().map(HttpField::toString).toList()));
336             ContentResponse response = request.send();
337
338             Code statusCode = HttpStatus.getCode(response.getStatus());
339             String responseBody = new String(response.getContent(), StandardCharsets.UTF_8);
340             logger.trace(" -returned: code {} body {}", statusCode, responseBody);
341
342             if (statusCode == Code.OK) {
343                 updateStatus(ThingStatus.ONLINE);
344                 return deserializer.deserialize(clazz, responseBody);
345             }
346
347             NetatmoException exception;
348             try {
349                 exception = new NetatmoException(deserializer.deserialize(ApiError.class, responseBody));
350             } catch (NetatmoException e) {
351                 exception = new NetatmoException("Error deserializing error: %s".formatted(statusCode.getMessage()));
352             }
353             throw exception;
354         } catch (NetatmoException e) {
355             if (e.getStatusCode() == ServiceError.MAXIMUM_USAGE_REACHED) {
356                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/maximum-usage-reached");
357                 prepareReconnection(null, null);
358             }
359             throw e;
360         } catch (InterruptedException e) {
361             Thread.currentThread().interrupt();
362             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
363             throw new NetatmoException("Request interrupted");
364         } catch (TimeoutException | ExecutionException e) {
365             if (retryCount > 0) {
366                 logger.debug("Request timedout, retry counter: {}", retryCount);
367                 return executeUri(uri, method, clazz, payload, contentType, retryCount - 1);
368             }
369             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/request-time-out");
370             prepareReconnection(null, null);
371             throw new NetatmoException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
372         }
373     }
374
375     public void identifyAllModulesAndApplyAction(BiFunction<NAModule, ThingUID, Optional<ThingUID>> action) {
376         ThingUID accountUID = getThing().getUID();
377         try {
378             AircareApi airCareApi = getRestManager(AircareApi.class);
379             if (airCareApi != null) { // Search Healthy Home Coaches
380                 ListBodyResponse<NAMain> body = airCareApi.getHomeCoachData(null).getBody();
381                 if (body != null) {
382                     body.getElements().stream().forEach(homeCoach -> action.apply(homeCoach, accountUID));
383                 }
384             }
385             WeatherApi weatherApi = getRestManager(WeatherApi.class);
386             if (weatherApi != null) { // Search owned or favorite stations
387                 weatherApi.getFavoriteAndGuestStationsData().stream().forEach(station -> {
388                     if (!station.isReadOnly() || getReadFriends()) {
389                         action.apply(station, accountUID).ifPresent(stationUID -> station.getModules().values().stream()
390                                 .forEach(module -> action.apply(module, stationUID)));
391                     }
392                 });
393             }
394             HomeApi homeApi = getRestManager(HomeApi.class);
395             if (homeApi != null) { // Search those depending from a home that has modules + not only weather modules
396                 homeApi.getHomesData(null, null).stream()
397                         .filter(h -> !(h.getFeatures().isEmpty()
398                                 || h.getFeatures().contains(FeatureArea.WEATHER) && h.getFeatures().size() == 1))
399                         .forEach(home -> {
400                             action.apply(home, accountUID).ifPresent(homeUID -> {
401                                 if (home instanceof HomeData.Security securityData) {
402                                     securityData.getKnownPersons().forEach(person -> action.apply(person, homeUID));
403                                 }
404                                 Map<String, ThingUID> bridgesUids = new HashMap<>();
405
406                                 home.getRooms().values().stream().forEach(room -> {
407                                     room.getModuleIds().stream().map(id -> home.getModules().get(id))
408                                             .map(m -> m != null ? m.getType().feature : FeatureArea.NONE)
409                                             .filter(f -> FeatureArea.ENERGY.equals(f)).findAny()
410                                             .ifPresent(f -> action.apply(room, homeUID)
411                                                     .ifPresent(roomUID -> bridgesUids.put(room.getId(), roomUID)));
412                                 });
413
414                                 // Creating modules that have no bridge first, avoiding weather station itself
415                                 home.getModules().values().stream()
416                                         .filter(module -> module.getType().feature != FeatureArea.WEATHER)
417                                         .sorted(comparing(HomeDataModule::getBridge, nullsFirst(naturalOrder())))
418                                         .forEach(module -> {
419                                             String bridgeId = module.getBridge();
420                                             if (bridgeId == null) {
421                                                 action.apply(module, homeUID).ifPresent(
422                                                         moduleUID -> bridgesUids.put(module.getId(), moduleUID));
423                                             } else {
424                                                 action.apply(module, bridgesUids.getOrDefault(bridgeId, homeUID));
425                                             }
426                                         });
427                             });
428                         });
429             }
430         } catch (NetatmoException e) {
431             logger.warn("Error while identifying all modules: {}", e.getMessage());
432         }
433     }
434
435     public boolean getReadFriends() {
436         return bindingConf.readFriends;
437     }
438
439     public boolean isConnected() {
440         return connectApi.isConnected();
441     }
442
443     public String getId() {
444         return (String) getThing().getConfiguration().get(ApiHandlerConfiguration.CLIENT_ID);
445     }
446
447     public UriBuilder formatAuthorizationUrl() {
448         return AuthenticationApi.getAuthorizationBuilder(getId());
449     }
450
451     @Override
452     public Collection<Class<? extends ThingHandlerService>> getServices() {
453         return Set.of(NetatmoDiscoveryService.class);
454     }
455
456     public Optional<WebhookServlet> getWebHookServlet() {
457         return webHookServlet;
458     }
459 }