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