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