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 org.openhab.binding.netatmo.internal.NetatmoBindingConstants.*;
17 import java.lang.reflect.InvocationTargetException;
18 import java.util.ArrayList;
19 import java.util.Collections;
20 import java.util.HashMap;
21 import java.util.HashSet;
22 import java.util.List;
24 import java.util.Optional;
26 import java.util.concurrent.CopyOnWriteArrayList;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
29 import java.util.stream.Stream;
31 import org.apache.oltu.oauth2.client.OAuthClient;
32 import org.apache.oltu.oauth2.client.URLConnectionClient;
33 import org.apache.oltu.oauth2.client.request.OAuthClientRequest;
34 import org.apache.oltu.oauth2.client.response.OAuthJSONAccessTokenResponse;
35 import org.apache.oltu.oauth2.common.exception.OAuthProblemException;
36 import org.apache.oltu.oauth2.common.exception.OAuthSystemException;
37 import org.apache.oltu.oauth2.common.message.types.GrantType;
38 import org.eclipse.jdt.annotation.NonNullByDefault;
39 import org.eclipse.jdt.annotation.Nullable;
40 import org.openhab.binding.netatmo.internal.config.NetatmoBridgeConfiguration;
41 import org.openhab.binding.netatmo.internal.webhook.NAWebhookCameraEvent;
42 import org.openhab.binding.netatmo.internal.webhook.NAWebhookCameraEventPerson;
43 import org.openhab.binding.netatmo.internal.webhook.WelcomeWebHookServlet;
44 import org.openhab.core.thing.Bridge;
45 import org.openhab.core.thing.Channel;
46 import org.openhab.core.thing.ChannelUID;
47 import org.openhab.core.thing.Thing;
48 import org.openhab.core.thing.ThingStatus;
49 import org.openhab.core.thing.ThingStatusDetail;
50 import org.openhab.core.thing.binding.BaseBridgeHandler;
51 import org.openhab.core.types.Command;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
55 import io.swagger.client.ApiClient;
56 import io.swagger.client.ApiException;
57 import io.swagger.client.api.HealthyhomecoachApi;
58 import io.swagger.client.api.PartnerApi;
59 import io.swagger.client.api.StationApi;
60 import io.swagger.client.api.ThermostatApi;
61 import io.swagger.client.api.WelcomeApi;
62 import io.swagger.client.auth.Authentication;
63 import io.swagger.client.auth.OAuth;
64 import io.swagger.client.model.NAHealthyHomeCoachDataBody;
65 import io.swagger.client.model.NAMeasureBodyElem;
66 import io.swagger.client.model.NAStationDataBody;
67 import io.swagger.client.model.NAThermostatDataBody;
68 import io.swagger.client.model.NAWelcomeHomeData;
71 * {@link NetatmoBridgeHandler} is the handler for a Netatmo API and connects it
72 * to the framework. The devices and modules uses the
73 * {@link NetatmoBridgeHandler} to request informations about their status
75 * @author Gaƫl L'hopital - Initial contribution OH2 version
76 * @author Rob Nielsen - Added day, week, and month measurements to the weather station and modules
80 public class NetatmoBridgeHandler extends BaseBridgeHandler {
81 private final Logger logger = LoggerFactory.getLogger(NetatmoBridgeHandler.class);
83 public NetatmoBridgeConfiguration configuration = new NetatmoBridgeConfiguration();
84 private @Nullable ScheduledFuture<?> refreshJob;
85 private @Nullable APICreator apiCreator;
86 private @Nullable WelcomeWebHookServlet webHookServlet;
87 private List<NetatmoDataListener> dataListeners = new CopyOnWriteArrayList<>();
89 private static class APICreator {
91 private final ApiClient apiClient;
92 private final Map<Class<?>, Object> apiMap;
94 private APICreator(ApiClient apiClient) {
96 this.apiClient = apiClient;
97 apiMap = new HashMap<>();
100 @SuppressWarnings("unchecked")
101 public <T> T getAPI(Class<T> apiClass) {
102 T api = (T) apiMap.get(apiClass);
105 api = apiClass.getDeclaredConstructor(ApiClient.class).newInstance(apiClient);
106 } catch (InstantiationException | IllegalAccessException | InvocationTargetException
107 | NoSuchMethodException e) {
108 throw new RuntimeException("Error on executing API class constructor!", e);
110 apiMap.put(apiClass, api);
116 public NetatmoBridgeHandler(Bridge bridge, @Nullable WelcomeWebHookServlet webHookServlet) {
118 this.webHookServlet = webHookServlet;
122 public void initialize() {
123 logger.debug("Initializing Netatmo API bridge handler.");
125 configuration = getConfigAs(NetatmoBridgeConfiguration.class);
126 scheduleTokenInitAndRefresh();
129 private void connectionSucceed() {
130 updateStatus(ThingStatus.ONLINE);
131 WelcomeWebHookServlet servlet = webHookServlet;
132 String webHookURI = getWebHookURI();
133 if (servlet != null && webHookURI != null) {
134 getWelcomeApi().ifPresent(api -> {
135 servlet.activate(this);
136 logger.debug("Setting up Netatmo Welcome WebHook");
137 api.addwebhook(webHookURI, WEBHOOK_APP);
142 private void scheduleTokenInitAndRefresh() {
143 refreshJob = scheduler.scheduleWithFixedDelay(() -> {
144 logger.debug("Initializing API Connection and scheduling token refresh every {}s",
145 configuration.reconnectInterval);
147 initializeApiClient();
148 // I use a connection to Netatmo API using PartnerAPI to ensure that API is reachable
149 getPartnerApi().partnerdevices();
151 } catch (ApiException e) {
152 switch (e.getCode()) {
153 case 404: // If no partner station has been associated - likely to happen - we'll have this
155 // but it means connection to API is OK
158 case 403: // Forbidden Access maybe too many requests ? Let's wait next cycle
159 logger.warn("Error 403 while connecting to Netatmo API, will retry in {} s",
160 configuration.reconnectInterval);
161 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
162 "Netatmo Access Forbidden, will retry in " + configuration.reconnectInterval
166 if (logger.isDebugEnabled()) {
167 // we also attach the stack trace
168 logger.error("Unable to connect Netatmo API : {}", e.getMessage(), e);
170 logger.error("Unable to connect Netatmo API : {}", e.getMessage());
172 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
173 "Unable to connect Netatmo API : " + e.getLocalizedMessage());
175 } catch (RuntimeException e) {
176 if (logger.isDebugEnabled()) {
177 logger.warn("Unable to connect Netatmo API : {}", e.getMessage(), e);
179 logger.warn("Unable to connect Netatmo API : {}", e.getMessage());
181 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
182 "Netatmo Access Failed, will retry in " + configuration.reconnectInterval + " seconds.");
184 // We'll do this every x seconds to guaranty token refresh
185 }, 2, configuration.reconnectInterval, TimeUnit.SECONDS);
188 private void initializeApiClient() {
190 ApiClient apiClient = new ApiClient();
192 OAuthClientRequest oAuthRequest = OAuthClientRequest.tokenLocation("https://api.netatmo.net/oauth2/token")
193 .setClientId(configuration.clientId).setClientSecret(configuration.clientSecret)
194 .setUsername(configuration.username).setPassword(configuration.password).setScope(getApiScope())
195 .setGrantType(GrantType.PASSWORD).buildBodyMessage();
197 OAuthClient oAuthClient = new OAuthClient(new URLConnectionClient());
199 OAuthJSONAccessTokenResponse accessTokenResponse = oAuthClient.accessToken(oAuthRequest,
200 OAuthJSONAccessTokenResponse.class);
201 String accessToken = accessTokenResponse.getAccessToken();
203 for (Authentication authentication : apiClient.getAuthentications().values()) {
204 if (authentication instanceof OAuth) {
205 ((OAuth) authentication).setAccessToken(accessToken);
209 apiCreator = new APICreator(apiClient);
210 } catch (OAuthSystemException | OAuthProblemException e) {
211 throw new RuntimeException("Error on trying to get an access token!", e);
215 private String getApiScope() {
216 List<String> scopes = new ArrayList<>();
218 if (configuration.readStation) {
219 scopes.add("read_station");
222 if (configuration.readThermostat) {
223 scopes.add("read_thermostat");
224 scopes.add("write_thermostat");
227 if (configuration.readHealthyHomeCoach) {
228 scopes.add("read_homecoach");
231 if (configuration.readWelcome) {
232 scopes.add("read_camera");
233 scopes.add("access_camera");
234 scopes.add("write_camera");
237 if (configuration.readPresence) {
238 scopes.add("read_presence");
239 scopes.add("access_presence");
242 return String.join(" ", scopes);
246 public void handleCommand(ChannelUID channelUID, Command command) {
247 logger.debug("Netatmo Bridge is read-only and does not handle commands");
250 public @Nullable PartnerApi getPartnerApi() {
251 return apiCreator != null ? apiCreator.getAPI(PartnerApi.class) : null;
254 public Optional<StationApi> getStationApi() {
255 return apiCreator != null ? Optional.of(apiCreator.getAPI(StationApi.class)) : Optional.empty();
258 public Optional<HealthyhomecoachApi> getHomeCoachApi() {
259 return apiCreator != null ? Optional.of(apiCreator.getAPI(HealthyhomecoachApi.class)) : Optional.empty();
262 public Optional<ThermostatApi> getThermostatApi() {
263 return apiCreator != null ? Optional.of(apiCreator.getAPI(ThermostatApi.class)) : Optional.empty();
266 public Optional<WelcomeApi> getWelcomeApi() {
267 return apiCreator != null ? Optional.of(apiCreator.getAPI(WelcomeApi.class)) : Optional.empty();
271 public void dispose() {
272 logger.debug("Running dispose()");
274 WelcomeWebHookServlet servlet = webHookServlet;
275 if (servlet != null && getWebHookURI() != null) {
276 getWelcomeApi().ifPresent(api -> {
277 logger.debug("Releasing Netatmo Welcome WebHook");
278 servlet.deactivate();
279 api.dropwebhook(WEBHOOK_APP);
283 ScheduledFuture<?> job = refreshJob;
290 public Optional<NAStationDataBody> getStationsDataBody(@Nullable String equipmentId) {
291 Optional<NAStationDataBody> data = getStationApi().map(api -> api.getstationsdata(equipmentId, true).getBody());
292 updateStatus(ThingStatus.ONLINE);
296 public List<Float> getStationMeasureResponses(String equipmentId, @Nullable String moduleId, String scale,
297 List<String> types) {
298 List<NAMeasureBodyElem> data = getStationApi()
299 .map(api -> api.getmeasure(equipmentId, scale, types, moduleId, null, "last", 1, true, false).getBody())
301 updateStatus(ThingStatus.ONLINE);
302 NAMeasureBodyElem element = data != null && !data.isEmpty() ? data.get(0) : null;
303 return element != null ? element.getValue().get(0) : Collections.emptyList();
306 public Optional<NAHealthyHomeCoachDataBody> getHomecoachDataBody(@Nullable String equipmentId) {
307 Optional<NAHealthyHomeCoachDataBody> data = getHomeCoachApi()
308 .map(api -> api.gethomecoachsdata(equipmentId).getBody());
309 updateStatus(ThingStatus.ONLINE);
313 public Optional<NAThermostatDataBody> getThermostatsDataBody(@Nullable String equipmentId) {
314 Optional<NAThermostatDataBody> data = getThermostatApi()
315 .map(api -> api.getthermostatsdata(equipmentId).getBody());
316 updateStatus(ThingStatus.ONLINE);
320 public Optional<NAWelcomeHomeData> getWelcomeDataBody(@Nullable String homeId) {
321 Optional<NAWelcomeHomeData> data = getWelcomeApi().map(api -> api.gethomedata(homeId, null).getBody());
322 updateStatus(ThingStatus.ONLINE);
327 * Returns the Url of the picture
329 * @return Url of the picture or UnDefType.UNDEF
331 public String getPictureUrl(@Nullable String id, @Nullable String key) {
332 StringBuilder ret = new StringBuilder();
333 if (id != null && key != null) {
334 ret.append(WELCOME_PICTURE_URL).append("?").append(WELCOME_PICTURE_IMAGEID).append("=").append(id)
335 .append("&").append(WELCOME_PICTURE_KEY).append("=").append(key);
337 return ret.toString();
340 public Optional<AbstractNetatmoThingHandler> findNAThing(@Nullable String searchedId) {
341 List<Thing> things = getThing().getThings();
342 Stream<AbstractNetatmoThingHandler> naHandlers = things.stream().map(Thing::getHandler)
343 .filter(AbstractNetatmoThingHandler.class::isInstance).map(AbstractNetatmoThingHandler.class::cast)
344 .filter(handler -> handler.matchesId(searchedId));
345 return naHandlers.findAny();
348 public void webHookEvent(NAWebhookCameraEvent event) {
349 // This currently the only known event type but I suspect usage can grow in the future...
350 if (event.getAppType() == NAWebhookCameraEvent.AppTypeEnum.CAMERA) {
351 Set<AbstractNetatmoThingHandler> modules = new HashSet<>();
352 if (WELCOME_EVENTS.contains(event.getEventType()) || PRESENCE_EVENTS.contains(event.getEventType())) {
353 String cameraId = event.getCameraId();
354 if (cameraId != null) {
355 Optional<AbstractNetatmoThingHandler> camera = findNAThing(cameraId);
356 camera.ifPresent(modules::add);
359 if (HOME_EVENTS.contains(event.getEventType())) {
360 String homeId = event.getHomeId();
361 if (homeId != null) {
362 Optional<AbstractNetatmoThingHandler> home = findNAThing(homeId);
363 home.ifPresent(modules::add);
366 if (PERSON_EVENTS.contains(event.getEventType())) {
367 List<NAWebhookCameraEventPerson> persons = event.getPersons();
368 persons.forEach(person -> {
369 String personId = person.getId();
370 if (personId != null) {
371 Optional<AbstractNetatmoThingHandler> personHandler = findNAThing(personId);
372 personHandler.ifPresent(modules::add);
376 modules.forEach(module -> {
377 Channel channel = module.getThing().getChannel(CHANNEL_WELCOME_HOME_EVENT);
378 if (channel != null) {
379 triggerChannel(channel.getUID(), event.getEventType().toString());
385 private @Nullable String getWebHookURI() {
386 String webHookURI = null;
387 WelcomeWebHookServlet webHookServlet = this.webHookServlet;
388 if (configuration.webHookUrl != null && (configuration.readWelcome || configuration.readPresence)
389 && webHookServlet != null) {
390 webHookURI = configuration.webHookUrl + webHookServlet.getPath();
395 public boolean registerDataListener(NetatmoDataListener dataListener) {
396 return dataListeners.add(dataListener);
399 public boolean unregisterDataListener(NetatmoDataListener dataListener) {
400 return dataListeners.remove(dataListener);
403 public void checkForNewThings(Object data) {
404 for (NetatmoDataListener dataListener : dataListeners) {
405 dataListener.onDataRefreshed(data);