]> git.basschouten.com Git - openhab-addons.git/blob
d210d91de3bca32d92bad03ca30be77a2fe134db
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.InputStream;
20 import java.lang.reflect.Constructor;
21 import java.net.URI;
22 import java.nio.charset.StandardCharsets;
23 import java.time.LocalDateTime;
24 import java.util.ArrayDeque;
25 import java.util.Collection;
26 import java.util.Deque;
27 import java.util.HashMap;
28 import java.util.Map;
29 import java.util.Objects;
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.HomeDataModule;
64 import org.openhab.binding.netatmo.internal.api.dto.NAMain;
65 import org.openhab.binding.netatmo.internal.api.dto.NAModule;
66 import org.openhab.binding.netatmo.internal.config.ApiHandlerConfiguration;
67 import org.openhab.binding.netatmo.internal.config.BindingConfiguration;
68 import org.openhab.binding.netatmo.internal.config.ConfigurationLevel;
69 import org.openhab.binding.netatmo.internal.deserialization.NADeserializer;
70 import org.openhab.binding.netatmo.internal.discovery.NetatmoDiscoveryService;
71 import org.openhab.binding.netatmo.internal.servlet.GrantServlet;
72 import org.openhab.binding.netatmo.internal.servlet.WebhookServlet;
73 import org.openhab.core.config.core.Configuration;
74 import org.openhab.core.library.types.DecimalType;
75 import org.openhab.core.thing.Bridge;
76 import org.openhab.core.thing.ChannelUID;
77 import org.openhab.core.thing.Thing;
78 import org.openhab.core.thing.ThingStatus;
79 import org.openhab.core.thing.ThingStatusDetail;
80 import org.openhab.core.thing.ThingUID;
81 import org.openhab.core.thing.binding.BaseBridgeHandler;
82 import org.openhab.core.thing.binding.ThingHandlerService;
83 import org.openhab.core.types.Command;
84 import org.osgi.service.http.HttpService;
85 import org.slf4j.Logger;
86 import org.slf4j.LoggerFactory;
87
88 /**
89  * {@link ApiBridgeHandler} is the handler for a Netatmo API and connects it to the framework.
90  *
91  * @author GaĆ«l L'hopital - Initial contribution
92  *
93  */
94 @NonNullByDefault
95 public class ApiBridgeHandler extends BaseBridgeHandler {
96     private static final int TIMEOUT_S = 20;
97
98     private final Logger logger = LoggerFactory.getLogger(ApiBridgeHandler.class);
99     private final BindingConfiguration bindingConf;
100     private final AuthenticationApi connectApi;
101     private final HttpClient httpClient;
102     private final NADeserializer deserializer;
103     private final HttpService httpService;
104
105     private Optional<ScheduledFuture<?>> connectJob = Optional.empty();
106     private Map<Class<? extends RestManager>, RestManager> managers = new HashMap<>();
107     private @Nullable WebhookServlet webHookServlet;
108     private @Nullable GrantServlet grantServlet;
109     private Deque<LocalDateTime> requestsTimestamps;
110     private final ChannelUID requestCountChannelUID;
111
112     public ApiBridgeHandler(Bridge bridge, HttpClient httpClient, NADeserializer deserializer,
113             BindingConfiguration configuration, HttpService httpService) {
114         super(bridge);
115         this.bindingConf = configuration;
116         this.connectApi = new AuthenticationApi(this, scheduler);
117         this.httpClient = httpClient;
118         this.deserializer = deserializer;
119         this.httpService = httpService;
120         this.requestsTimestamps = new ArrayDeque<>(200);
121         this.requestCountChannelUID = new ChannelUID(getThing().getUID(), GROUP_MONITORING, CHANNEL_REQUEST_COUNT);
122     }
123
124     @Override
125     public void initialize() {
126         logger.debug("Initializing Netatmo API bridge handler.");
127         updateStatus(ThingStatus.UNKNOWN);
128         GrantServlet servlet = new GrantServlet(this, httpService);
129         servlet.startListening();
130         grantServlet = servlet;
131         scheduler.execute(() -> openConnection(null, null));
132     }
133
134     public void openConnection(@Nullable String code, @Nullable String redirectUri) {
135         ApiHandlerConfiguration configuration = getConfiguration();
136         ConfigurationLevel level = configuration.check();
137         switch (level) {
138             case EMPTY_CLIENT_ID:
139             case EMPTY_CLIENT_SECRET:
140                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, level.message);
141                 break;
142             case REFRESH_TOKEN_NEEDED:
143                 if (code == null || redirectUri == null) {
144                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, level.message);
145                     break;
146                 } // else we can proceed to get the token refresh
147             case COMPLETED:
148                 try {
149                     logger.debug("Connecting to Netatmo API.");
150
151                     String refreshToken = connectApi.authorize(configuration, code, redirectUri);
152
153                     if (configuration.refreshToken.isBlank()) {
154                         logger.trace("Adding refresh token to configuration : {}", refreshToken);
155                         Configuration thingConfig = editConfiguration();
156                         thingConfig.put(ApiHandlerConfiguration.REFRESH_TOKEN, refreshToken);
157                         updateConfiguration(thingConfig);
158                         configuration = getConfiguration();
159                     }
160
161                     if (!configuration.webHookUrl.isBlank()) {
162                         SecurityApi securityApi = getRestManager(SecurityApi.class);
163                         if (securityApi != null) {
164                             WebhookServlet servlet = new WebhookServlet(this, httpService, deserializer, securityApi,
165                                     configuration.webHookUrl, configuration.webHookPostfix);
166                             servlet.startListening();
167                             this.webHookServlet = servlet;
168                         }
169                     }
170
171                     updateStatus(ThingStatus.ONLINE);
172
173                     getThing().getThings().stream().filter(Thing::isEnabled).map(Thing::getHandler)
174                             .filter(Objects::nonNull).map(CommonInterface.class::cast)
175                             .forEach(CommonInterface::expireData);
176
177                 } catch (NetatmoException e) {
178                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
179                     prepareReconnection(code, redirectUri);
180                 }
181                 break;
182         }
183     }
184
185     public ApiHandlerConfiguration getConfiguration() {
186         return getConfigAs(ApiHandlerConfiguration.class);
187     }
188
189     private void prepareReconnection(@Nullable String code, @Nullable String redirectUri) {
190         connectApi.disconnect();
191         freeConnectJob();
192         connectJob = Optional.of(scheduler.schedule(() -> openConnection(code, redirectUri),
193                 getConfiguration().reconnectInterval, TimeUnit.SECONDS));
194     }
195
196     private void freeConnectJob() {
197         connectJob.ifPresent(j -> j.cancel(true));
198         connectJob = Optional.empty();
199     }
200
201     @Override
202     public void dispose() {
203         logger.debug("Shutting down Netatmo API bridge handler.");
204         WebhookServlet localWebHook = this.webHookServlet;
205         if (localWebHook != null) {
206             localWebHook.dispose();
207         }
208         GrantServlet localGrant = this.grantServlet;
209         if (localGrant != null) {
210             localGrant.dispose();
211         }
212         connectApi.dispose();
213         freeConnectJob();
214         super.dispose();
215     }
216
217     @Override
218     public void handleCommand(ChannelUID channelUID, Command command) {
219         logger.debug("Netatmo Bridge is read-only and does not handle commands");
220     }
221
222     @SuppressWarnings("unchecked")
223     public <T extends RestManager> @Nullable T getRestManager(Class<T> clazz) {
224         if (!managers.containsKey(clazz)) {
225             try {
226                 Constructor<T> constructor = clazz.getConstructor(getClass());
227                 T instance = constructor.newInstance(this);
228                 Set<Scope> expected = instance.getRequiredScopes();
229                 if (connectApi.matchesScopes(expected)) {
230                     managers.put(clazz, instance);
231                 } else {
232                     logger.info("Unable to instantiate {}, expected scope {} is not active", clazz, expected);
233                 }
234             } catch (SecurityException | ReflectiveOperationException e) {
235                 logger.warn("Error invoking RestManager constructor for class {} : {}", clazz, e.getMessage());
236             }
237         }
238         return (T) managers.get(clazz);
239     }
240
241     public synchronized <T> T executeUri(URI uri, HttpMethod method, Class<T> clazz, @Nullable String payload,
242             @Nullable String contentType, int retryCount) throws NetatmoException {
243         try {
244             logger.trace("executeUri {}  {} ", method.toString(), uri);
245
246             Request request = httpClient.newRequest(uri).method(method).timeout(TIMEOUT_S, TimeUnit.SECONDS);
247
248             String auth = connectApi.getAuthorization();
249             if (auth != null) {
250                 request.header(HttpHeader.AUTHORIZATION, auth);
251             }
252
253             if (payload != null && contentType != null
254                     && (HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method))) {
255                 InputStream stream = new ByteArrayInputStream(payload.getBytes(StandardCharsets.UTF_8));
256                 try (InputStreamContentProvider inputStreamContentProvider = new InputStreamContentProvider(stream)) {
257                     request.content(inputStreamContentProvider, contentType);
258                 }
259                 logger.trace(" -with payload : {} ", payload);
260             }
261
262             if (isLinked(requestCountChannelUID)) {
263                 LocalDateTime now = LocalDateTime.now();
264                 LocalDateTime oneHourAgo = now.minusHours(1);
265                 requestsTimestamps.addLast(now);
266                 while (requestsTimestamps.getFirst().isBefore(oneHourAgo)) {
267                     requestsTimestamps.removeFirst();
268                 }
269                 updateState(requestCountChannelUID, new DecimalType(requestsTimestamps.size()));
270             }
271             logger.trace(" -with headers : {} ",
272                     String.join(", ", request.getHeaders().stream().map(HttpField::toString).toList()));
273             ContentResponse response = request.send();
274
275             Code statusCode = HttpStatus.getCode(response.getStatus());
276             String responseBody = new String(response.getContent(), StandardCharsets.UTF_8);
277             logger.trace(" -returned : code {} body {}", statusCode, responseBody);
278
279             if (statusCode == Code.OK) {
280                 return deserializer.deserialize(clazz, responseBody);
281             }
282
283             NetatmoException exception;
284             try {
285                 exception = new NetatmoException(deserializer.deserialize(ApiError.class, responseBody));
286             } catch (NetatmoException e) {
287                 exception = new NetatmoException("Error deserializing error : %s".formatted(statusCode.getMessage()));
288             }
289             throw exception;
290         } catch (NetatmoException e) {
291             if (e.getStatusCode() == ServiceError.MAXIMUM_USAGE_REACHED) {
292                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
293                 prepareReconnection(null, null);
294             }
295             throw e;
296         } catch (InterruptedException e) {
297             Thread.currentThread().interrupt();
298             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
299             throw new NetatmoException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
300         } catch (TimeoutException | ExecutionException e) {
301             if (retryCount > 0) {
302                 logger.debug("Request timedout, retry counter : {}", retryCount);
303                 return executeUri(uri, method, clazz, payload, contentType, retryCount - 1);
304             }
305             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/request-time-out");
306             prepareReconnection(null, null);
307             throw new NetatmoException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
308         }
309     }
310
311     public void identifyAllModulesAndApplyAction(BiFunction<NAModule, ThingUID, Optional<ThingUID>> action) {
312         ThingUID accountUID = getThing().getUID();
313         try {
314             AircareApi airCareApi = getRestManager(AircareApi.class);
315             if (airCareApi != null) { // Search Healthy Home Coaches
316                 ListBodyResponse<NAMain> body = airCareApi.getHomeCoachData(null).getBody();
317                 if (body != null) {
318                     body.getElements().stream().forEach(homeCoach -> action.apply(homeCoach, accountUID));
319                 }
320             }
321             WeatherApi weatherApi = getRestManager(WeatherApi.class);
322             if (weatherApi != null) { // Search owned or favorite stations
323                 weatherApi.getFavoriteAndGuestStationsData().stream().forEach(station -> {
324                     if (!station.isReadOnly() || getReadFriends()) {
325                         action.apply(station, accountUID).ifPresent(stationUID -> station.getModules().values().stream()
326                                 .forEach(module -> action.apply(module, stationUID)));
327                     }
328                 });
329             }
330             HomeApi homeApi = getRestManager(HomeApi.class);
331             if (homeApi != null) { // Search those depending from a home that has modules + not only weather modules
332                 homeApi.getHomesData(null, null).stream()
333                         .filter(h -> !(h.getFeatures().isEmpty()
334                                 || h.getFeatures().contains(FeatureArea.WEATHER) && h.getFeatures().size() == 1))
335                         .forEach(home -> {
336                             action.apply(home, accountUID).ifPresent(homeUID -> {
337                                 home.getKnownPersons().forEach(person -> action.apply(person, homeUID));
338
339                                 Map<String, ThingUID> bridgesUids = new HashMap<>();
340
341                                 home.getRooms().values().stream().forEach(room -> {
342                                     room.getModuleIds().stream().map(id -> home.getModules().get(id))
343                                             .map(m -> m != null ? m.getType().feature : FeatureArea.NONE)
344                                             .filter(f -> FeatureArea.ENERGY.equals(f)).findAny().ifPresent(f -> {
345                                                 action.apply(room, homeUID)
346                                                         .ifPresent(roomUID -> bridgesUids.put(room.getId(), roomUID));
347                                             });
348                                 });
349
350                                 // Creating modules that have no bridge first, avoiding weather station itself
351                                 home.getModules().values().stream()
352                                         .filter(module -> module.getType().feature != FeatureArea.WEATHER)
353                                         .sorted(comparing(HomeDataModule::getBridge, nullsFirst(naturalOrder())))
354                                         .forEach(module -> {
355                                             String bridgeId = module.getBridge();
356                                             if (bridgeId == null) {
357                                                 action.apply(module, homeUID).ifPresent(
358                                                         moduleUID -> bridgesUids.put(module.getId(), moduleUID));
359                                             } else {
360                                                 action.apply(module, bridgesUids.getOrDefault(bridgeId, homeUID));
361                                             }
362                                         });
363                             });
364                         });
365             }
366         } catch (NetatmoException e) {
367             logger.warn("Error while identifying all modules : {}", e.getMessage());
368         }
369     }
370
371     public boolean getReadFriends() {
372         return bindingConf.readFriends;
373     }
374
375     public boolean isConnected() {
376         return connectApi.isConnected();
377     }
378
379     public String getId() {
380         return (String) getThing().getConfiguration().get(ApiHandlerConfiguration.CLIENT_ID);
381     }
382
383     public UriBuilder formatAuthorizationUrl() {
384         return AuthenticationApi.getAuthorizationBuilder(getId());
385     }
386
387     @Override
388     public Collection<Class<? extends ThingHandlerService>> getServices() {
389         return Set.of(NetatmoDiscoveryService.class);
390     }
391
392     public Optional<WebhookServlet> getWebHookServlet() {
393         return Optional.ofNullable(webHookServlet);
394     }
395 }