2 * Copyright (c) 2010-2022 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.InputStream;
20 import java.lang.reflect.Constructor;
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;
29 import java.util.Objects;
30 import java.util.Optional;
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;
38 import javax.ws.rs.core.UriBuilder;
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.HttpHeader;
47 import org.eclipse.jetty.http.HttpMethod;
48 import org.eclipse.jetty.http.HttpStatus;
49 import org.eclipse.jetty.http.HttpStatus.Code;
50 import org.openhab.binding.netatmo.internal.api.AircareApi;
51 import org.openhab.binding.netatmo.internal.api.ApiError;
52 import org.openhab.binding.netatmo.internal.api.AuthenticationApi;
53 import org.openhab.binding.netatmo.internal.api.HomeApi;
54 import org.openhab.binding.netatmo.internal.api.ListBodyResponse;
55 import org.openhab.binding.netatmo.internal.api.NetatmoException;
56 import org.openhab.binding.netatmo.internal.api.RestManager;
57 import org.openhab.binding.netatmo.internal.api.SecurityApi;
58 import org.openhab.binding.netatmo.internal.api.WeatherApi;
59 import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.FeatureArea;
60 import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.Scope;
61 import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.ServiceError;
62 import org.openhab.binding.netatmo.internal.api.dto.HomeDataModule;
63 import org.openhab.binding.netatmo.internal.api.dto.NAMain;
64 import org.openhab.binding.netatmo.internal.api.dto.NAModule;
65 import org.openhab.binding.netatmo.internal.config.ApiHandlerConfiguration;
66 import org.openhab.binding.netatmo.internal.config.BindingConfiguration;
67 import org.openhab.binding.netatmo.internal.config.ConfigurationLevel;
68 import org.openhab.binding.netatmo.internal.deserialization.NADeserializer;
69 import org.openhab.binding.netatmo.internal.discovery.NetatmoDiscoveryService;
70 import org.openhab.binding.netatmo.internal.servlet.GrantServlet;
71 import org.openhab.binding.netatmo.internal.servlet.WebhookServlet;
72 import org.openhab.core.config.core.Configuration;
73 import org.openhab.core.library.types.DecimalType;
74 import org.openhab.core.thing.Bridge;
75 import org.openhab.core.thing.ChannelUID;
76 import org.openhab.core.thing.Thing;
77 import org.openhab.core.thing.ThingStatus;
78 import org.openhab.core.thing.ThingStatusDetail;
79 import org.openhab.core.thing.ThingUID;
80 import org.openhab.core.thing.binding.BaseBridgeHandler;
81 import org.openhab.core.thing.binding.ThingHandlerService;
82 import org.openhab.core.types.Command;
83 import org.osgi.service.http.HttpService;
84 import org.slf4j.Logger;
85 import org.slf4j.LoggerFactory;
88 * {@link ApiBridgeHandler} is the handler for a Netatmo API and connects it to the framework.
90 * @author Gaƫl L'hopital - Initial contribution
94 public class ApiBridgeHandler extends BaseBridgeHandler {
95 private static final int TIMEOUT_S = 20;
97 private final Logger logger = LoggerFactory.getLogger(ApiBridgeHandler.class);
98 private final BindingConfiguration bindingConf;
99 private final AuthenticationApi connectApi;
100 private final HttpClient httpClient;
101 private final NADeserializer deserializer;
102 private final HttpService httpService;
104 private Optional<ScheduledFuture<?>> connectJob = Optional.empty();
105 private Map<Class<? extends RestManager>, RestManager> managers = new HashMap<>();
106 private @Nullable WebhookServlet webHookServlet;
107 private @Nullable GrantServlet grantServlet;
108 private Deque<LocalDateTime> requestsTimestamps;
109 private final ChannelUID requestCountChannelUID;
111 public ApiBridgeHandler(Bridge bridge, HttpClient httpClient, NADeserializer deserializer,
112 BindingConfiguration configuration, HttpService httpService) {
114 this.bindingConf = configuration;
115 this.connectApi = new AuthenticationApi(this, scheduler);
116 this.httpClient = httpClient;
117 this.deserializer = deserializer;
118 this.httpService = httpService;
119 this.requestsTimestamps = new ArrayDeque<>(200);
120 this.requestCountChannelUID = new ChannelUID(getThing().getUID(), GROUP_MONITORING, CHANNEL_REQUEST_COUNT);
124 public void initialize() {
125 logger.debug("Initializing Netatmo API bridge handler.");
126 updateStatus(ThingStatus.UNKNOWN);
127 GrantServlet servlet = new GrantServlet(this, httpService);
128 servlet.startListening();
129 grantServlet = servlet;
130 scheduler.execute(() -> openConnection(null, null));
133 public void openConnection(@Nullable String code, @Nullable String redirectUri) {
134 ApiHandlerConfiguration configuration = getConfiguration();
135 ConfigurationLevel level = configuration.check();
137 case EMPTY_CLIENT_ID:
138 case EMPTY_CLIENT_SECRET:
139 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, level.message);
141 case REFRESH_TOKEN_NEEDED:
142 if (code == null || redirectUri == null) {
143 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, level.message);
145 } // else we can proceed to get the token refresh
148 logger.debug("Connecting to Netatmo API.");
150 String refreshToken = connectApi.authorize(configuration, code, redirectUri);
152 if (configuration.refreshToken.isBlank()) {
153 Configuration thingConfig = editConfiguration();
154 thingConfig.put(ApiHandlerConfiguration.REFRESH_TOKEN, refreshToken);
155 updateConfiguration(thingConfig);
156 configuration = getConfiguration();
159 if (!configuration.webHookUrl.isBlank()) {
160 SecurityApi securityApi = getRestManager(SecurityApi.class);
161 if (securityApi != null) {
162 WebhookServlet servlet = new WebhookServlet(this, httpService, deserializer, securityApi,
163 configuration.webHookUrl);
164 servlet.startListening();
165 this.webHookServlet = servlet;
169 updateStatus(ThingStatus.ONLINE);
171 getThing().getThings().stream().filter(Thing::isEnabled).map(Thing::getHandler)
172 .filter(Objects::nonNull).map(CommonInterface.class::cast)
173 .forEach(CommonInterface::expireData);
175 } catch (NetatmoException e) {
176 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
177 prepareReconnection(code, redirectUri);
183 public ApiHandlerConfiguration getConfiguration() {
184 return getConfigAs(ApiHandlerConfiguration.class);
187 private void prepareReconnection(@Nullable String code, @Nullable String redirectUri) {
188 connectApi.disconnect();
190 connectJob = Optional.of(scheduler.schedule(() -> openConnection(code, redirectUri),
191 getConfiguration().reconnectInterval, TimeUnit.SECONDS));
194 private void freeConnectJob() {
195 connectJob.ifPresent(j -> j.cancel(true));
196 connectJob = Optional.empty();
200 public void dispose() {
201 logger.debug("Shutting down Netatmo API bridge handler.");
202 WebhookServlet localWebHook = this.webHookServlet;
203 if (localWebHook != null) {
204 localWebHook.dispose();
206 GrantServlet localGrant = this.grantServlet;
207 if (localGrant != null) {
208 localGrant.dispose();
210 connectApi.dispose();
216 public void handleCommand(ChannelUID channelUID, Command command) {
217 logger.debug("Netatmo Bridge is read-only and does not handle commands");
220 @SuppressWarnings("unchecked")
221 public <T extends RestManager> @Nullable T getRestManager(Class<T> clazz) {
222 if (!managers.containsKey(clazz)) {
224 Constructor<T> constructor = clazz.getConstructor(getClass());
225 T instance = constructor.newInstance(this);
226 Set<Scope> expected = instance.getRequiredScopes();
227 if (connectApi.matchesScopes(expected)) {
228 managers.put(clazz, instance);
230 logger.info("Unable to instantiate {}, expected scope {} is not active", clazz, expected);
232 } catch (SecurityException | ReflectiveOperationException e) {
233 logger.warn("Error invoking RestManager constructor for class {} : {}", clazz, e.getMessage());
236 return (T) managers.get(clazz);
239 public synchronized <T> T executeUri(URI uri, HttpMethod method, Class<T> clazz, @Nullable String payload,
240 @Nullable String contentType, int retryCount) throws NetatmoException {
242 logger.trace("executeUri {} {} ", method.toString(), uri);
244 Request request = httpClient.newRequest(uri).method(method).timeout(TIMEOUT_S, TimeUnit.SECONDS);
246 String auth = connectApi.getAuthorization();
248 request.header(HttpHeader.AUTHORIZATION, auth);
251 if (payload != null && contentType != null
252 && (HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method))) {
253 InputStream stream = new ByteArrayInputStream(payload.getBytes(StandardCharsets.UTF_8));
254 try (InputStreamContentProvider inputStreamContentProvider = new InputStreamContentProvider(stream)) {
255 request.content(inputStreamContentProvider, contentType);
259 if (isLinked(requestCountChannelUID)) {
260 LocalDateTime now = LocalDateTime.now();
261 LocalDateTime oneHourAgo = now.minusHours(1);
262 requestsTimestamps.addLast(now);
263 while (requestsTimestamps.getFirst().isBefore(oneHourAgo)) {
264 requestsTimestamps.removeFirst();
266 updateState(requestCountChannelUID, new DecimalType(requestsTimestamps.size()));
268 ContentResponse response = request.send();
270 Code statusCode = HttpStatus.getCode(response.getStatus());
271 String responseBody = new String(response.getContent(), StandardCharsets.UTF_8);
272 logger.trace("executeUri returned : code {} body {}", statusCode, responseBody);
274 if (statusCode != Code.OK) {
276 ApiError error = deserializer.deserialize(ApiError.class, responseBody);
277 throw new NetatmoException(error);
278 } catch (NetatmoException e) {
279 logger.debug("Error deserializing payload from error response", e);
280 throw new NetatmoException(statusCode.getMessage());
283 return deserializer.deserialize(clazz, responseBody);
284 } catch (NetatmoException e) {
285 if (e.getStatusCode() == ServiceError.MAXIMUM_USAGE_REACHED) {
286 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
287 prepareReconnection(null, null);
290 } catch (InterruptedException e) {
291 Thread.currentThread().interrupt();
292 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
293 throw new NetatmoException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
294 } catch (TimeoutException | ExecutionException e) {
295 if (retryCount > 0) {
296 logger.debug("Request timedout, retry counter : {}", retryCount);
297 return executeUri(uri, method, clazz, payload, contentType, retryCount - 1);
299 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/request-time-out");
300 prepareReconnection(null, null);
301 throw new NetatmoException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
305 public void identifyAllModulesAndApplyAction(BiFunction<NAModule, ThingUID, Optional<ThingUID>> action) {
306 ThingUID accountUID = getThing().getUID();
308 AircareApi airCareApi = getRestManager(AircareApi.class);
309 if (airCareApi != null) { // Search Healthy Home Coaches
310 ListBodyResponse<NAMain> body = airCareApi.getHomeCoachData(null).getBody();
312 body.getElements().stream().forEach(homeCoach -> action.apply(homeCoach, accountUID));
315 WeatherApi weatherApi = getRestManager(WeatherApi.class);
316 if (weatherApi != null) { // Search owned or favorite stations
317 weatherApi.getFavoriteAndGuestStationsData().stream().forEach(station -> {
318 if (!station.isReadOnly() || getReadFriends()) {
319 action.apply(station, accountUID).ifPresent(stationUID -> station.getModules().values().stream()
320 .forEach(module -> action.apply(module, stationUID)));
324 HomeApi homeApi = getRestManager(HomeApi.class);
325 if (homeApi != null) { // Search those depending from a home that has modules + not only weather modules
326 homeApi.getHomesData(null, null).stream()
327 .filter(h -> !(h.getFeatures().isEmpty()
328 || h.getFeatures().contains(FeatureArea.WEATHER) && h.getFeatures().size() == 1))
330 action.apply(home, accountUID).ifPresent(homeUID -> {
331 home.getKnownPersons().forEach(person -> action.apply(person, homeUID));
333 Map<String, ThingUID> bridgesUids = new HashMap<>();
335 home.getRooms().values().stream().forEach(room -> {
336 room.getModuleIds().stream().map(id -> home.getModules().get(id))
337 .map(m -> m != null ? m.getType().feature : FeatureArea.NONE)
338 .filter(f -> FeatureArea.ENERGY.equals(f)).findAny().ifPresent(f -> {
339 action.apply(room, homeUID)
340 .ifPresent(roomUID -> bridgesUids.put(room.getId(), roomUID));
344 // Creating modules that have no bridge first, avoiding weather station itself
345 home.getModules().values().stream()
346 .filter(module -> module.getType().feature != FeatureArea.WEATHER)
347 .sorted(comparing(HomeDataModule::getBridge, nullsFirst(naturalOrder())))
349 String bridgeId = module.getBridge();
350 if (bridgeId == null) {
351 action.apply(module, homeUID).ifPresent(
352 moduleUID -> bridgesUids.put(module.getId(), moduleUID));
354 action.apply(module, bridgesUids.getOrDefault(bridgeId, homeUID));
360 } catch (NetatmoException e) {
361 logger.warn("Error while identifying all modules : {}", e.getMessage());
365 public boolean getReadFriends() {
366 return bindingConf.readFriends;
369 public boolean isConnected() {
370 return connectApi.isConnected();
373 public String getId() {
374 return (String) getThing().getConfiguration().get(ApiHandlerConfiguration.CLIENT_ID);
377 public UriBuilder formatAuthorizationUrl() {
378 return AuthenticationApi.getAuthorizationBuilder(getId());
382 public Collection<Class<? extends ThingHandlerService>> getServices() {
383 return Set.of(NetatmoDiscoveryService.class);
386 public Optional<WebhookServlet> getWebHookServlet() {
387 return Optional.ofNullable(webHookServlet);