]> git.basschouten.com Git - openhab-addons.git/blob
884f90ac13a249e5e84b48978d73b6bbbf24aa6e
[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.bticinosmarther.internal.handler;
14
15 import static org.openhab.binding.bticinosmarther.internal.SmartherBindingConstants.*;
16
17 import java.io.IOException;
18 import java.net.InetAddress;
19 import java.net.URI;
20 import java.net.URISyntaxException;
21 import java.net.UnknownHostException;
22 import java.time.Duration;
23 import java.util.ArrayList;
24 import java.util.Collection;
25 import java.util.Collections;
26 import java.util.List;
27 import java.util.Optional;
28 import java.util.concurrent.Future;
29 import java.util.concurrent.TimeUnit;
30 import java.util.stream.Collectors;
31
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.eclipse.jetty.client.HttpClient;
35 import org.openhab.binding.bticinosmarther.internal.account.SmartherAccountHandler;
36 import org.openhab.binding.bticinosmarther.internal.account.SmartherNotificationHandler;
37 import org.openhab.binding.bticinosmarther.internal.api.SmartherApi;
38 import org.openhab.binding.bticinosmarther.internal.api.dto.Location;
39 import org.openhab.binding.bticinosmarther.internal.api.dto.Module;
40 import org.openhab.binding.bticinosmarther.internal.api.dto.ModuleStatus;
41 import org.openhab.binding.bticinosmarther.internal.api.dto.Notification;
42 import org.openhab.binding.bticinosmarther.internal.api.dto.Plant;
43 import org.openhab.binding.bticinosmarther.internal.api.dto.Program;
44 import org.openhab.binding.bticinosmarther.internal.api.dto.Sender;
45 import org.openhab.binding.bticinosmarther.internal.api.dto.Subscription;
46 import org.openhab.binding.bticinosmarther.internal.api.exception.SmartherAuthorizationException;
47 import org.openhab.binding.bticinosmarther.internal.api.exception.SmartherGatewayException;
48 import org.openhab.binding.bticinosmarther.internal.config.SmartherBridgeConfiguration;
49 import org.openhab.binding.bticinosmarther.internal.discovery.SmartherModuleDiscoveryService;
50 import org.openhab.binding.bticinosmarther.internal.model.BridgeStatus;
51 import org.openhab.binding.bticinosmarther.internal.model.ModuleSettings;
52 import org.openhab.binding.bticinosmarther.internal.util.StringUtil;
53 import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
54 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
55 import org.openhab.core.auth.client.oauth2.OAuthClientService;
56 import org.openhab.core.auth.client.oauth2.OAuthException;
57 import org.openhab.core.auth.client.oauth2.OAuthFactory;
58 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
59 import org.openhab.core.cache.ExpiringCache;
60 import org.openhab.core.config.core.Configuration;
61 import org.openhab.core.library.types.DecimalType;
62 import org.openhab.core.library.types.OnOffType;
63 import org.openhab.core.thing.Bridge;
64 import org.openhab.core.thing.Channel;
65 import org.openhab.core.thing.ChannelUID;
66 import org.openhab.core.thing.ThingStatus;
67 import org.openhab.core.thing.ThingStatusDetail;
68 import org.openhab.core.thing.ThingUID;
69 import org.openhab.core.thing.binding.BaseBridgeHandler;
70 import org.openhab.core.thing.binding.ThingHandlerService;
71 import org.openhab.core.types.Command;
72 import org.openhab.core.types.RefreshType;
73 import org.openhab.core.types.State;
74 import org.slf4j.Logger;
75 import org.slf4j.LoggerFactory;
76
77 /**
78  * The {@code SmartherBridgeHandler} class is responsible of the handling of a Smarther Bridge thing.
79  * The Smarther Bridge is used to manage a set of Smarther Chronothermostat Modules registered under the same
80  * Legrand/Bticino account credentials.
81  *
82  * @author Fabio Possieri - Initial contribution
83  */
84 @NonNullByDefault
85 public class SmartherBridgeHandler extends BaseBridgeHandler
86         implements SmartherAccountHandler, SmartherNotificationHandler, AccessTokenRefreshListener {
87
88     private static final long POLL_INITIAL_DELAY = 5;
89
90     private final Logger logger = LoggerFactory.getLogger(SmartherBridgeHandler.class);
91
92     private final OAuthFactory oAuthFactory;
93     private final HttpClient httpClient;
94
95     // Bridge configuration
96     private SmartherBridgeConfiguration config;
97
98     // Field members assigned in initialize method
99     private @Nullable Future<?> pollFuture;
100     private @Nullable OAuthClientService oAuthService;
101     private @Nullable SmartherApi smartherApi;
102     private @Nullable ExpiringCache<List<Location>> locationCache;
103     private @Nullable BridgeStatus bridgeStatus;
104
105     /**
106      * Constructs a {@code SmartherBridgeHandler} for the given Bridge thing, authorization factory and http client.
107      *
108      * @param bridge
109      *            the {@link Bridge} thing to be used
110      * @param oAuthFactory
111      *            the OAuth2 authorization factory to be used
112      * @param httpClient
113      *            the http client to be used
114      */
115     public SmartherBridgeHandler(Bridge bridge, OAuthFactory oAuthFactory, HttpClient httpClient) {
116         super(bridge);
117         this.oAuthFactory = oAuthFactory;
118         this.httpClient = httpClient;
119         this.config = new SmartherBridgeConfiguration();
120     }
121
122     @Override
123     public Collection<Class<? extends ThingHandlerService>> getServices() {
124         return Collections.singleton(SmartherModuleDiscoveryService.class);
125     }
126
127     // ===========================================================================
128     //
129     // Bridge thing lifecycle management methods
130     //
131     // ===========================================================================
132
133     @Override
134     public void initialize() {
135         logger.debug("Bridge[{}] Initialize handler", thing.getUID());
136
137         this.config = getConfigAs(SmartherBridgeConfiguration.class);
138         if (StringUtil.isBlank(config.getSubscriptionKey())) {
139             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
140                     "The 'Subscription Key' property is not set or empty. If you have an older thing please recreate it.");
141             return;
142         }
143         if (StringUtil.isBlank(config.getClientId())) {
144             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
145                     "The 'Client Id' property is not set or empty. If you have an older thing please recreate it.");
146             return;
147         }
148         if (StringUtil.isBlank(config.getClientSecret())) {
149             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
150                     "The 'Client Secret' property is not set or empty. If you have an older thing please recreate it.");
151             return;
152         }
153
154         // Initialize OAuth2 authentication support
155         final OAuthClientService localOAuthService = oAuthFactory.createOAuthClientService(thing.getUID().getAsString(),
156                 SMARTHER_API_TOKEN_URL, SMARTHER_AUTHORIZE_URL, config.getClientId(), config.getClientSecret(),
157                 SMARTHER_API_SCOPES, false);
158         localOAuthService.addAccessTokenRefreshListener(SmartherBridgeHandler.this);
159         this.oAuthService = localOAuthService;
160
161         // Initialize Smarther Api
162         final SmartherApi localSmartherApi = new SmartherApi(localOAuthService, config.getSubscriptionKey(), scheduler,
163                 httpClient);
164         this.smartherApi = localSmartherApi;
165
166         // Initialize locations (plant Ids) local cache
167         final ExpiringCache<List<Location>> localLocationCache = new ExpiringCache<>(
168                 Duration.ofMinutes(config.getStatusRefreshPeriod()), this::locationCacheAction);
169         this.locationCache = localLocationCache;
170
171         // Initialize bridge local status
172         final BridgeStatus localBridgeStatus = new BridgeStatus();
173         this.bridgeStatus = localBridgeStatus;
174
175         updateStatus(ThingStatus.UNKNOWN);
176
177         schedulePoll();
178
179         logger.debug("Bridge[{}] Finished initializing!", thing.getUID());
180     }
181
182     @Override
183     public void handleCommand(ChannelUID channelUID, Command command) {
184         switch (channelUID.getId()) {
185             case CHANNEL_CONFIG_FETCH_LOCATIONS:
186                 if (command instanceof OnOffType) {
187                     if (OnOffType.ON.equals(command)) {
188                         logger.debug(
189                                 "Bridge[{}] Manually triggered channel to remotely fetch the updated client locations list",
190                                 thing.getUID());
191                         expireCache();
192                         getLocations();
193                         updateChannelState(CHANNEL_CONFIG_FETCH_LOCATIONS, OnOffType.OFF);
194                     }
195                     return;
196                 }
197                 break;
198         }
199
200         if (command instanceof RefreshType) {
201             // Avoid logging wrong command when refresh command is sent
202             return;
203         }
204
205         logger.debug("Bridge[{}] Received command {} of wrong type {} on channel {}", thing.getUID(), command,
206                 command.getClass().getTypeName(), channelUID.getId());
207     }
208
209     @Override
210     public void handleRemoval() {
211         super.handleRemoval();
212         stopPoll(true);
213     }
214
215     @Override
216     public void dispose() {
217         logger.debug("Bridge[{}] Dispose handler", thing.getUID());
218         final OAuthClientService localOAuthService = this.oAuthService;
219         if (localOAuthService != null) {
220             localOAuthService.removeAccessTokenRefreshListener(this);
221         }
222         this.oAuthFactory.ungetOAuthService(thing.getUID().getAsString());
223         stopPoll(true);
224         logger.debug("Bridge[{}] Finished disposing!", thing.getUID());
225     }
226
227     // ===========================================================================
228     //
229     // Bridge data cache management methods
230     //
231     // ===========================================================================
232
233     /**
234      * Returns the available locations to be cached for this Bridge.
235      *
236      * @return the available locations to be cached for this Bridge, or {@code null} if the list of available locations
237      *         cannot be retrieved
238      */
239     private @Nullable List<Location> locationCacheAction() {
240         try {
241             // Retrieve the plants list from the API Gateway
242             final List<Plant> plants = getPlants();
243
244             List<Location> locations;
245             if (config.isUseNotifications()) {
246                 // Retrieve the subscriptions list from the API Gateway
247                 final List<Subscription> subscriptions = getSubscriptions();
248
249                 // Enrich the notifications list with externally registered subscriptions
250                 updateNotifications(subscriptions);
251
252                 // Get the notifications list from bridge config
253                 final List<String> notifications = config.getNotifications();
254
255                 locations = plants.stream().map(p -> Location.fromPlant(p, subscriptions.stream()
256                         .filter(s -> s.getPlantId().equals(p.getId()) && notifications.contains(s.getSubscriptionId()))
257                         .findFirst())).collect(Collectors.toList());
258             } else {
259                 locations = plants.stream().map(p -> Location.fromPlant(p)).collect(Collectors.toList());
260             }
261             logger.debug("Bridge[{}] Available locations: {}", thing.getUID(), locations);
262
263             return locations;
264
265         } catch (SmartherGatewayException e) {
266             logger.warn("Bridge[{}] Cannot retrieve available locations: {}", thing.getUID(), e.getMessage());
267             return null;
268         }
269     }
270
271     /**
272      * Updates this Bridge local notifications list with externally registered subscriptions.
273      *
274      * @param subscriptions
275      *            the externally registered subscriptions to be added to the local notifications list
276      */
277     private void updateNotifications(List<Subscription> subscriptions) {
278         // Get the notifications list from bridge config
279         List<String> notifications = config.getNotifications();
280
281         for (Subscription s : subscriptions) {
282             if (s.getEndpointUrl().equalsIgnoreCase(config.getNotificationUrl())
283                     && !notifications.contains(s.getSubscriptionId())) {
284                 // Add the external subscription to notifications list
285                 notifications = config.addNotification(s.getSubscriptionId());
286
287                 // Save the updated notifications list back to bridge config
288                 Configuration configuration = editConfiguration();
289                 configuration.put(PROPERTY_NOTIFICATIONS, notifications);
290                 updateConfiguration(configuration);
291             }
292         }
293     }
294
295     /**
296      * Sets all the cache to "expired" for this Bridge.
297      */
298     private void expireCache() {
299         logger.debug("Bridge[{}] Invalidating location cache", thing.getUID());
300         final ExpiringCache<List<Location>> localLocationCache = this.locationCache;
301         if (localLocationCache != null) {
302             localLocationCache.invalidateValue();
303         }
304     }
305
306     // ===========================================================================
307     //
308     // Bridge status polling mechanism methods
309     //
310     // ===========================================================================
311
312     /**
313      * Starts a new scheduler to periodically poll and update this Bridge status.
314      */
315     private void schedulePoll() {
316         stopPoll(false);
317
318         // Schedule poll to start after POLL_INITIAL_DELAY sec and run periodically based on status refresh period
319         final Future<?> localPollFuture = scheduler.scheduleWithFixedDelay(this::poll, POLL_INITIAL_DELAY,
320                 config.getStatusRefreshPeriod() * 60, TimeUnit.SECONDS);
321         this.pollFuture = localPollFuture;
322
323         logger.debug("Bridge[{}] Scheduled poll for {} sec out, then every {} min", thing.getUID(), POLL_INITIAL_DELAY,
324                 config.getStatusRefreshPeriod());
325     }
326
327     /**
328      * Cancels all running poll schedulers.
329      *
330      * @param mayInterruptIfRunning
331      *            {@code true} if the thread executing this task should be interrupted, {@code false} if the in-progress
332      *            tasks are allowed to complete
333      */
334     private synchronized void stopPoll(boolean mayInterruptIfRunning) {
335         final Future<?> localPollFuture = this.pollFuture;
336         if (localPollFuture != null) {
337             if (!localPollFuture.isCancelled()) {
338                 localPollFuture.cancel(mayInterruptIfRunning);
339             }
340             this.pollFuture = null;
341         }
342     }
343
344     /**
345      * Polls to update this Bridge status, calling the Smarther API to refresh its plants list.
346      *
347      * @return {@code true} if the method completes without errors, {@code false} otherwise
348      */
349     private synchronized boolean poll() {
350         try {
351             onAccessTokenResponse(getAccessTokenResponse());
352
353             expireCache();
354             getLocations();
355
356             updateStatus(ThingStatus.ONLINE);
357             return true;
358         } catch (SmartherAuthorizationException e) {
359             logger.warn("Bridge[{}] Authorization error during polling: {}", thing.getUID(), e.getMessage());
360             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
361         } catch (RuntimeException e) {
362             // All other exceptions apart from Authorization and Gateway issues
363             logger.warn("Bridge[{}] Unexpected error during polling, please report if this keeps occurring: ",
364                     thing.getUID(), e);
365             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
366         }
367         schedulePoll();
368         return false;
369     }
370
371     @Override
372     public void onAccessTokenResponse(@Nullable AccessTokenResponse tokenResponse) {
373         logger.trace("Bridge[{}] Got access token: {}", thing.getUID(),
374                 (tokenResponse != null) ? tokenResponse.getAccessToken() : "none");
375     }
376
377     // ===========================================================================
378     //
379     // Bridge convenience methods
380     //
381     // ===========================================================================
382
383     /**
384      * Convenience method to get this Bridge configuration.
385      *
386      * @return a {@link SmartherBridgeConfiguration} object containing the Bridge configuration
387      */
388     public SmartherBridgeConfiguration getSmartherBridgeConfig() {
389         return config;
390     }
391
392     /**
393      * Convenience method to get the access token from Smarther API authorization layer.
394      *
395      * @return the autorization access token, may be {@code null}
396      *
397      * @throws {@link SmartherAuthorizationException}
398      *             in case of authorization issues with the Smarther API
399      */
400     private @Nullable AccessTokenResponse getAccessTokenResponse() throws SmartherAuthorizationException {
401         try {
402             final OAuthClientService localOAuthService = this.oAuthService;
403             if (localOAuthService != null) {
404                 return localOAuthService.getAccessTokenResponse();
405             }
406             return null;
407         } catch (OAuthException | IOException | OAuthResponseException | RuntimeException e) {
408             throw new SmartherAuthorizationException(e.getMessage());
409         }
410     }
411
412     /**
413      * Convenience method to update the given Channel state "only" if the Channel is linked.
414      *
415      * @param channelId
416      *            the identifier of the Channel to be updated
417      * @param state
418      *            the new state to be applied to the given Channel
419      */
420     private void updateChannelState(String channelId, State state) {
421         final Channel channel = thing.getChannel(channelId);
422
423         if (channel != null && isLinked(channel.getUID())) {
424             updateState(channel.getUID(), state);
425         }
426     }
427
428     /**
429      * Convenience method to update the Smarther API calls counter for this Bridge.
430      */
431     private void updateApiCallsCounter() {
432         final BridgeStatus localBridgeStatus = this.bridgeStatus;
433         if (localBridgeStatus != null) {
434             updateChannelState(CHANNEL_STATUS_API_CALLS_HANDLED,
435                     new DecimalType(localBridgeStatus.incrementApiCallsHandled()));
436         }
437     }
438
439     /**
440      * Convenience method to check and get the Smarther API instance for this Bridge.
441      *
442      * @return the Smarther API instance
443      *
444      * @throws {@link SmartherGatewayException}
445      *             in case the Smarther API instance is {@code null}
446      */
447     private SmartherApi getSmartherApi() throws SmartherGatewayException {
448         final SmartherApi localSmartherApi = this.smartherApi;
449         if (localSmartherApi == null) {
450             throw new SmartherGatewayException("Smarther API instance is null");
451         }
452         return localSmartherApi;
453     }
454
455     // ===========================================================================
456     //
457     // Implementation of the SmartherAccountHandler interface
458     //
459     // ===========================================================================
460
461     @Override
462     public ThingUID getUID() {
463         return thing.getUID();
464     }
465
466     @Override
467     public String getLabel() {
468         return StringUtil.defaultString(thing.getLabel());
469     }
470
471     @Override
472     public List<Location> getLocations() {
473         final ExpiringCache<List<Location>> localLocationCache = this.locationCache;
474         final List<Location> locations = (localLocationCache != null) ? localLocationCache.getValue() : null;
475         return (locations != null) ? locations : Collections.emptyList();
476     }
477
478     @Override
479     public boolean hasLocation(String plantId) {
480         final ExpiringCache<List<Location>> localLocationCache = this.locationCache;
481         final List<Location> locations = (localLocationCache != null) ? localLocationCache.getValue() : null;
482         return (locations != null) ? locations.stream().anyMatch(l -> l.getPlantId().equals(plantId)) : false;
483     }
484
485     @Override
486     public List<Plant> getPlants() throws SmartherGatewayException {
487         updateApiCallsCounter();
488         return getSmartherApi().getPlants();
489     }
490
491     @Override
492     public List<Subscription> getSubscriptions() throws SmartherGatewayException {
493         updateApiCallsCounter();
494         return getSmartherApi().getSubscriptions();
495     }
496
497     @Override
498     public String subscribePlant(String plantId, String notificationUrl) throws SmartherGatewayException {
499         updateApiCallsCounter();
500         return getSmartherApi().subscribePlant(plantId, notificationUrl);
501     }
502
503     @Override
504     public void unsubscribePlant(String plantId, String subscriptionId) throws SmartherGatewayException {
505         updateApiCallsCounter();
506         getSmartherApi().unsubscribePlant(plantId, subscriptionId);
507     }
508
509     @Override
510     public List<Module> getLocationModules(Location location) {
511         try {
512             updateApiCallsCounter();
513             return getSmartherApi().getPlantModules(location.getPlantId());
514         } catch (SmartherGatewayException e) {
515             return new ArrayList<>();
516         }
517     }
518
519     @Override
520     public ModuleStatus getModuleStatus(String plantId, String moduleId) throws SmartherGatewayException {
521         updateApiCallsCounter();
522         return getSmartherApi().getModuleStatus(plantId, moduleId);
523     }
524
525     @Override
526     public boolean setModuleStatus(ModuleSettings moduleSettings) throws SmartherGatewayException {
527         updateApiCallsCounter();
528         return getSmartherApi().setModuleStatus(moduleSettings);
529     }
530
531     @Override
532     public List<Program> getModulePrograms(String plantId, String moduleId) throws SmartherGatewayException {
533         updateApiCallsCounter();
534         return getSmartherApi().getModulePrograms(plantId, moduleId);
535     }
536
537     @Override
538     public boolean isAuthorized() {
539         try {
540             final AccessTokenResponse tokenResponse = getAccessTokenResponse();
541             onAccessTokenResponse(tokenResponse);
542
543             return (tokenResponse != null && tokenResponse.getAccessToken() != null
544                     && tokenResponse.getRefreshToken() != null);
545         } catch (SmartherAuthorizationException e) {
546             return false;
547         }
548     }
549
550     @Override
551     public boolean isOnline() {
552         return (thing.getStatus() == ThingStatus.ONLINE);
553     }
554
555     @Override
556     public String authorize(String redirectUrl, String reqCode, String notificationUrl)
557             throws SmartherGatewayException {
558         try {
559             logger.debug("Bridge[{}] Call API gateway to get access token. RedirectUri: {}", thing.getUID(),
560                     redirectUrl);
561
562             final OAuthClientService localOAuthService = this.oAuthService;
563             if (localOAuthService == null) {
564                 throw new SmartherAuthorizationException("Authorization service is null");
565             }
566
567             // OAuth2 call to get access token from received authorization code
568             localOAuthService.getAccessTokenResponseByAuthorizationCode(reqCode, redirectUrl);
569
570             // Store the notification URL in bridge configuration
571             Configuration configuration = editConfiguration();
572             configuration.put(PROPERTY_NOTIFICATION_URL, notificationUrl);
573             updateConfiguration(configuration);
574             config.setNotificationUrl(notificationUrl);
575             logger.debug("Bridge[{}] Store notification URL: {}", thing.getUID(), notificationUrl);
576
577             // Reschedule the polling thread
578             schedulePoll();
579
580             return config.getClientId();
581         } catch (OAuthResponseException e) {
582             throw new SmartherAuthorizationException(e.toString(), e);
583         } catch (OAuthException | IOException e) {
584             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
585             throw new SmartherGatewayException(e.getMessage(), e);
586         }
587     }
588
589     @Override
590     public boolean equalsThingUID(String thingUID) {
591         return thing.getUID().getAsString().equals(thingUID);
592     }
593
594     @Override
595     public String formatAuthorizationUrl(String redirectUri) {
596         try {
597             final OAuthClientService localOAuthService = this.oAuthService;
598             if (localOAuthService != null) {
599                 return localOAuthService.getAuthorizationUrl(redirectUri, null, thing.getUID().getAsString());
600             }
601         } catch (OAuthException e) {
602             logger.warn("Bridge[{}] Error constructing AuthorizationUrl: {}", thing.getUID(), e.getMessage());
603         }
604         return "";
605     }
606
607     // ===========================================================================
608     //
609     // Implementation of the SmartherNotificationHandler interface
610     //
611     // ===========================================================================
612
613     @Override
614     public boolean useNotifications() {
615         return config.isUseNotifications();
616     }
617
618     @Override
619     public synchronized void registerNotification(String plantId) throws SmartherGatewayException {
620         if (!config.isUseNotifications()) {
621             return;
622         }
623
624         final ExpiringCache<List<Location>> localLocationCache = this.locationCache;
625         if (localLocationCache != null) {
626             List<Location> locations = localLocationCache.getValue();
627             if (locations != null) {
628                 final Optional<Location> maybeLocation = locations.stream().filter(l -> l.getPlantId().equals(plantId))
629                         .findFirst();
630                 if (maybeLocation.isPresent()) {
631                     Location location = maybeLocation.get();
632                     if (!location.hasSubscription()) {
633                         // Validate notification Url (must be non-null and https)
634                         final String notificationUrl = config.getNotificationUrl();
635                         if (isValidNotificationUrl(notificationUrl)) {
636                             // Call gateway to register plant subscription
637                             String subscriptionId = subscribePlant(plantId, config.getNotificationUrl());
638                             logger.debug("Bridge[{}] Notification registered: [plantId={}, subscriptionId={}]",
639                                     thing.getUID(), plantId, subscriptionId);
640
641                             // Add the new subscription to notifications list
642                             List<String> notifications = config.addNotification(subscriptionId);
643
644                             // Save the updated notifications list back to bridge config
645                             Configuration configuration = editConfiguration();
646                             configuration.put(PROPERTY_NOTIFICATIONS, notifications);
647                             updateConfiguration(configuration);
648
649                             // Update the local locationCache with the added data
650                             locations.stream().forEach(l -> {
651                                 if (l.getPlantId().equals(plantId)) {
652                                     l.setSubscription(subscriptionId, config.getNotificationUrl());
653                                 }
654                             });
655                             localLocationCache.putValue(locations);
656                         } else {
657                             logger.warn(
658                                     "Bridge[{}] Invalid notification Url [{}]: must be non-null, public https address",
659                                     thing.getUID(), notificationUrl);
660                         }
661                     }
662                 }
663             }
664         }
665     }
666
667     @Override
668     public void handleNotification(Notification notification) {
669         final Sender sender = notification.getSender();
670         if (sender != null) {
671             final BridgeStatus localBridgeStatus = this.bridgeStatus;
672             if (localBridgeStatus != null) {
673                 logger.debug("Bridge[{}] Notification received: [id={}]", thing.getUID(), notification.getId());
674                 updateChannelState(CHANNEL_STATUS_NOTIFS_RECEIVED,
675                         new DecimalType(localBridgeStatus.incrementNotificationsReceived()));
676
677                 final String plantId = sender.getPlant().getId();
678                 final String moduleId = sender.getPlant().getModule().getId();
679                 Optional<SmartherModuleHandler> maybeModuleHandler = getThing().getThings().stream()
680                         .map(t -> (SmartherModuleHandler) t.getHandler()).filter(h -> h.isLinkedTo(plantId, moduleId))
681                         .findFirst();
682
683                 if (config.isUseNotifications() && maybeModuleHandler.isPresent()) {
684                     maybeModuleHandler.get().handleNotification(notification);
685                 } else {
686                     logger.debug("Bridge[{}] Notification rejected: no module handler available", thing.getUID());
687                     updateChannelState(CHANNEL_STATUS_NOTIFS_REJECTED,
688                             new DecimalType(localBridgeStatus.incrementNotificationsRejected()));
689                 }
690             }
691         }
692     }
693
694     @Override
695     public synchronized void unregisterNotification(String plantId) throws SmartherGatewayException {
696         if (!config.isUseNotifications()) {
697             return;
698         }
699
700         final ExpiringCache<List<Location>> localLocationCache = this.locationCache;
701         if (localLocationCache != null) {
702             List<Location> locations = localLocationCache.getValue();
703
704             final long remainingModules = getThing().getThings().stream()
705                     .map(t -> (SmartherModuleHandler) t.getHandler()).filter(h -> h.getPlantId().equals(plantId))
706                     .count();
707
708             if (locations != null && remainingModules == 0) {
709                 final Optional<Location> maybeLocation = locations.stream().filter(l -> l.getPlantId().equals(plantId))
710                         .findFirst();
711                 if (maybeLocation.isPresent()) {
712                     Location location = maybeLocation.get();
713                     final String subscriptionId = location.getSubscriptionId();
714                     if (location.hasSubscription() && (subscriptionId != null)) {
715                         // Call gateway to unregister plant subscription
716                         unsubscribePlant(plantId, subscriptionId);
717                         logger.debug("Bridge[{}] Notification unregistered: [plantId={}, subscriptionId={}]",
718                                 thing.getUID(), plantId, subscriptionId);
719
720                         // Remove the subscription from notifications list
721                         List<String> notifications = config.removeNotification(subscriptionId);
722
723                         // Save the updated notifications list back to bridge config
724                         Configuration configuration = editConfiguration();
725                         configuration.put(PROPERTY_NOTIFICATIONS, notifications);
726                         updateConfiguration(configuration);
727
728                         // Update the local locationCache with the removed data
729                         locations.stream().forEach(l -> {
730                             if (l.getPlantId().equals(plantId)) {
731                                 l.unsetSubscription();
732                             }
733                         });
734                         localLocationCache.putValue(locations);
735                     }
736                 }
737             }
738         }
739     }
740
741     /**
742      * Checks if the passed string is a formally valid Notification Url (non-null, public https address).
743      *
744      * @param str
745      *            the string to check
746      *
747      * @return {@code true} if the given string is a formally valid Notification Url, {@code false} otherwise
748      */
749     private boolean isValidNotificationUrl(@Nullable String str) {
750         try {
751             if (str != null) {
752                 URI maybeValidNotificationUrl = new URI(str);
753                 if (HTTPS_SCHEMA.equals(maybeValidNotificationUrl.getScheme())) {
754                     InetAddress address = InetAddress.getByName(maybeValidNotificationUrl.getHost());
755                     if (!address.isLoopbackAddress() && !address.isSiteLocalAddress()) {
756                         return true;
757                     }
758                 }
759             }
760             return false;
761         } catch (URISyntaxException | UnknownHostException e) {
762             return false;
763         }
764     }
765 }