]> git.basschouten.com Git - openhab-addons.git/blob
5fc8b04041fdcb26f15a5afbd211ebd6a2ac38f2
[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.upnpcontrol.internal.handler;
14
15 import java.util.ArrayList;
16 import java.util.Collections;
17 import java.util.HashMap;
18 import java.util.List;
19 import java.util.Map;
20 import java.util.concurrent.CompletableFuture;
21 import java.util.concurrent.ExecutionException;
22 import java.util.concurrent.ScheduledExecutorService;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25 import java.util.concurrent.TimeoutException;
26 import java.util.regex.Pattern;
27 import java.util.stream.Collectors;
28
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.jupnp.model.meta.RemoteDevice;
32 import org.openhab.binding.upnpcontrol.internal.UpnpChannelName;
33 import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
34 import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
35 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
36 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlConfiguration;
37 import org.openhab.binding.upnpcontrol.internal.queue.UpnpPlaylistsListener;
38 import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
39 import org.openhab.core.common.ThreadPoolManager;
40 import org.openhab.core.io.transport.upnp.UpnpIOParticipant;
41 import org.openhab.core.io.transport.upnp.UpnpIOService;
42 import org.openhab.core.thing.Channel;
43 import org.openhab.core.thing.ChannelUID;
44 import org.openhab.core.thing.Thing;
45 import org.openhab.core.thing.ThingStatus;
46 import org.openhab.core.thing.ThingStatusDetail;
47 import org.openhab.core.thing.binding.BaseThingHandler;
48 import org.openhab.core.thing.binding.builder.ChannelBuilder;
49 import org.openhab.core.thing.binding.builder.ThingBuilder;
50 import org.openhab.core.thing.type.ChannelTypeUID;
51 import org.openhab.core.types.CommandDescription;
52 import org.openhab.core.types.CommandDescriptionBuilder;
53 import org.openhab.core.types.CommandOption;
54 import org.openhab.core.types.StateDescription;
55 import org.openhab.core.types.StateDescriptionFragmentBuilder;
56 import org.openhab.core.types.StateOption;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
59
60 /**
61  * The {@link UpnpHandler} is the base class for {@link UpnpRendererHandler} and {@link UpnpServerHandler}. The base
62  * class implements UPnPConnectionManager service actions.
63  *
64  * @author Mark Herwege - Initial contribution
65  * @author Karel Goderis - Based on UPnP logic in Sonos binding
66  */
67 @NonNullByDefault
68 public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOParticipant, UpnpPlaylistsListener {
69
70     private final Logger logger = LoggerFactory.getLogger(UpnpHandler.class);
71
72     // UPnP constants
73     static final String CONNECTION_MANAGER = "ConnectionManager";
74     static final String CONNECTION_ID = "ConnectionID";
75     static final String AV_TRANSPORT_ID = "AVTransportID";
76     static final String RCS_ID = "RcsID";
77     static final Pattern PROTOCOL_PATTERN = Pattern.compile("(?:.*):(?:.*):(.*):(?:.*)");
78
79     protected UpnpIOService upnpIOService;
80
81     protected volatile @Nullable RemoteDevice device;
82
83     // The handlers can potentially create an important number of tasks, therefore put them in a separate thread pool
84     protected ScheduledExecutorService upnpScheduler = ThreadPoolManager.getScheduledPool("binding-upnpcontrol");
85
86     private boolean updateChannels;
87     private final List<Channel> updatedChannels = new ArrayList<>();
88     private final List<ChannelUID> updatedChannelUIDs = new ArrayList<>();
89
90     protected volatile int connectionId = 0; // UPnP Connection Id
91     protected volatile int avTransportId = 0; // UPnP AVTtransport Id
92     protected volatile int rcsId = 0; // UPnP Rendering Control Id
93
94     protected UpnpControlBindingConfiguration bindingConfig;
95     protected UpnpControlConfiguration config;
96
97     protected final Object invokeActionLock = new Object();
98
99     protected @Nullable ScheduledFuture<?> pollingJob;
100     protected final Object jobLock = new Object();
101
102     protected volatile @Nullable CompletableFuture<Boolean> isConnectionIdSet;
103     protected volatile @Nullable CompletableFuture<Boolean> isAvTransportIdSet;
104     protected volatile @Nullable CompletableFuture<Boolean> isRcsIdSet;
105
106     protected static final int SUBSCRIPTION_DURATION_SECONDS = 3600;
107     protected List<String> serviceSubscriptions = new ArrayList<>();
108     protected volatile @Nullable ScheduledFuture<?> subscriptionRefreshJob;
109     protected final Runnable subscriptionRefresh = () -> {
110         for (String subscription : serviceSubscriptions) {
111             removeSubscription(subscription);
112             addSubscription(subscription, SUBSCRIPTION_DURATION_SECONDS);
113         }
114     };
115     protected volatile boolean upnpSubscribed;
116
117     protected UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
118     protected UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider;
119
120     public UpnpHandler(Thing thing, UpnpIOService upnpIOService, UpnpControlBindingConfiguration configuration,
121             UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
122             UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider) {
123         super(thing);
124
125         this.upnpIOService = upnpIOService;
126
127         this.bindingConfig = configuration;
128
129         this.upnpStateDescriptionProvider = upnpStateDescriptionProvider;
130         this.upnpCommandDescriptionProvider = upnpCommandDescriptionProvider;
131
132         // Get this in constructor, so the UDN is immediately available from the config. The concrete classes should
133         // update the config from the initialize method.
134         config = getConfigAs(UpnpControlConfiguration.class);
135     }
136
137     @Override
138     public void initialize() {
139         config = getConfigAs(UpnpControlConfiguration.class);
140
141         upnpIOService.registerParticipant(this);
142
143         UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
144         UpnpControlUtil.playlistsSubscribe(this);
145     }
146
147     @Override
148     public void dispose() {
149         cancelPollingJob();
150         removeSubscriptions();
151
152         UpnpControlUtil.playlistsUnsubscribe(this);
153
154         CompletableFuture<Boolean> connectionIdFuture = isConnectionIdSet;
155         if (connectionIdFuture != null) {
156             connectionIdFuture.complete(false);
157             isConnectionIdSet = null;
158         }
159         CompletableFuture<Boolean> avTransportIdFuture = isAvTransportIdSet;
160         if (avTransportIdFuture != null) {
161             avTransportIdFuture.complete(false);
162             isAvTransportIdSet = null;
163         }
164         CompletableFuture<Boolean> rcsIdFuture = isRcsIdSet;
165         if (rcsIdFuture != null) {
166             rcsIdFuture.complete(false);
167             isRcsIdSet = null;
168         }
169
170         updateChannels = false;
171         updatedChannels.clear();
172         updatedChannelUIDs.clear();
173
174         upnpIOService.removeStatusListener(this);
175         upnpIOService.unregisterParticipant(this);
176     }
177
178     private void cancelPollingJob() {
179         ScheduledFuture<?> job = pollingJob;
180
181         if (job != null) {
182             job.cancel(true);
183         }
184         pollingJob = null;
185     }
186
187     /**
188      * To be called from implementing classes when initializing the device, to start initialization refresh
189      */
190     protected void initDevice() {
191         String udn = getUDN();
192         if ((udn != null) && !udn.isEmpty()) {
193             updateStatus(ThingStatus.UNKNOWN);
194
195             if (config.refresh == 0) {
196                 upnpScheduler.submit(this::initJob);
197             } else {
198                 pollingJob = upnpScheduler.scheduleWithFixedDelay(this::initJob, 0, config.refresh, TimeUnit.SECONDS);
199             }
200         } else {
201             String msg = String.format("@text/offline.no-udn [ \"%s\" ]", thing.getLabel());
202             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
203         }
204     }
205
206     /**
207      * Job to be executed in an asynchronous process when initializing a device. This checks if the connection id's are
208      * correctly set up for the connection. It can also be called from a polling job to get the thing back online when
209      * connection is lost.
210      */
211     protected abstract void initJob();
212
213     @Override
214     protected void updateStatus(ThingStatus status) {
215         ThingStatus currentStatus = thing.getStatus();
216
217         super.updateStatus(status);
218
219         // When status changes to ThingStatus.ONLINE, make sure to refresh all linked channels
220         if (!status.equals(currentStatus) && status.equals(ThingStatus.ONLINE)) {
221             thing.getChannels().forEach(channel -> {
222                 if (isLinked(channel.getUID())) {
223                     channelLinked(channel.getUID());
224                 }
225             });
226         }
227     }
228
229     /**
230      * Method called when a the remote device represented by the thing for this handler is added to the jupnp
231      * {@link RegistryListener} or is updated. Configuration info can be retrieved from the {@link RemoteDevice}.
232      *
233      * @param device
234      */
235     public void updateDeviceConfig(RemoteDevice device) {
236         this.device = device;
237     };
238
239     protected void updateStateDescription(ChannelUID channelUID, List<StateOption> stateOptionList) {
240         StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false)
241                 .withOptions(stateOptionList).build().toStateDescription();
242         upnpStateDescriptionProvider.setDescription(channelUID, stateDescription);
243     }
244
245     protected void updateCommandDescription(ChannelUID channelUID, List<CommandOption> commandOptionList) {
246         CommandDescription commandDescription = CommandDescriptionBuilder.create().withCommandOptions(commandOptionList)
247                 .build();
248         upnpCommandDescriptionProvider.setDescription(channelUID, commandDescription);
249     }
250
251     protected void createChannel(@Nullable UpnpChannelName upnpChannelName) {
252         if ((upnpChannelName != null)) {
253             createChannel(upnpChannelName.getChannelId(), upnpChannelName.getLabel(), upnpChannelName.getDescription(),
254                     upnpChannelName.getItemType(), upnpChannelName.getChannelType());
255         }
256     }
257
258     protected void createChannel(String channelId, String label, String description, String itemType,
259             String channelType) {
260         ChannelUID channelUID = new ChannelUID(thing.getUID(), channelId);
261
262         if (thing.getChannel(channelUID) != null) {
263             // channel already exists
264             logger.trace("UPnP device {}, channel {} already exists", thing.getLabel(), channelId);
265             return;
266         }
267
268         ChannelTypeUID channelTypeUID = new ChannelTypeUID(channelType);
269         Channel channel = ChannelBuilder.create(channelUID).withLabel(label).withDescription(description)
270                 .withAcceptedItemType(itemType).withType(channelTypeUID).build();
271
272         logger.debug("UPnP device {}, created channel {}", thing.getLabel(), channelId);
273
274         updatedChannels.add(channel);
275         updatedChannelUIDs.add(channelUID);
276         updateChannels = true;
277     }
278
279     protected void updateChannels() {
280         if (updateChannels) {
281             List<Channel> channels = thing.getChannels().stream().filter(c -> !updatedChannelUIDs.contains(c.getUID()))
282                     .collect(Collectors.toList());
283             channels.addAll(updatedChannels);
284             final ThingBuilder thingBuilder = editThing();
285             thingBuilder.withChannels(channels);
286             updateThing(thingBuilder.build());
287         }
288         updatedChannels.clear();
289         updatedChannelUIDs.clear();
290         updateChannels = false;
291     }
292
293     /**
294      * Invoke PrepareForConnection on the UPnP Connection Manager.
295      * Result is received in {@link onValueReceived}.
296      *
297      * @param remoteProtocolInfo
298      * @param peerConnectionManager
299      * @param peerConnectionId
300      * @param direction
301      */
302     protected void prepareForConnection(String remoteProtocolInfo, String peerConnectionManager, int peerConnectionId,
303             String direction) {
304         CompletableFuture<Boolean> settingConnection = isConnectionIdSet;
305         CompletableFuture<Boolean> settingAVTransport = isAvTransportIdSet;
306         CompletableFuture<Boolean> settingRcs = isRcsIdSet;
307         if (settingConnection != null) {
308             settingConnection.complete(false);
309         }
310         if (settingAVTransport != null) {
311             settingAVTransport.complete(false);
312         }
313         if (settingRcs != null) {
314             settingRcs.complete(false);
315         }
316
317         // Set new futures, so we don't try to use service when connection id's are not known yet
318         isConnectionIdSet = new CompletableFuture<Boolean>();
319         isAvTransportIdSet = new CompletableFuture<Boolean>();
320         isRcsIdSet = new CompletableFuture<Boolean>();
321
322         HashMap<String, String> inputs = new HashMap<String, String>();
323         inputs.put("RemoteProtocolInfo", remoteProtocolInfo);
324         inputs.put("PeerConnectionManager", peerConnectionManager);
325         inputs.put("PeerConnectionID", Integer.toString(peerConnectionId));
326         inputs.put("Direction", direction);
327
328         invokeAction(CONNECTION_MANAGER, "PrepareForConnection", inputs);
329     }
330
331     /**
332      * Invoke ConnectionComplete on UPnP Connection Manager.
333      */
334     protected void connectionComplete() {
335         Map<String, String> inputs = Collections.singletonMap(CONNECTION_ID, Integer.toString(connectionId));
336
337         invokeAction(CONNECTION_MANAGER, "ConnectionComplete", inputs);
338     }
339
340     /**
341      * Invoke GetCurrentConnectionIDs on the UPnP Connection Manager.
342      * Result is received in {@link onValueReceived}.
343      */
344     protected void getCurrentConnectionIDs() {
345         Map<String, String> inputs = Collections.emptyMap();
346
347         invokeAction(CONNECTION_MANAGER, "GetCurrentConnectionIDs", inputs);
348     }
349
350     /**
351      * Invoke GetCurrentConnectionInfo on the UPnP Connection Manager.
352      * Result is received in {@link onValueReceived}.
353      */
354     protected void getCurrentConnectionInfo() {
355         CompletableFuture<Boolean> settingAVTransport = isAvTransportIdSet;
356         CompletableFuture<Boolean> settingRcs = isRcsIdSet;
357         if (settingAVTransport != null) {
358             settingAVTransport.complete(false);
359         }
360         if (settingRcs != null) {
361             settingRcs.complete(false);
362         }
363
364         // Set new futures, so we don't try to use service when connection id's are not known yet
365         isAvTransportIdSet = new CompletableFuture<Boolean>();
366         isRcsIdSet = new CompletableFuture<Boolean>();
367
368         // ConnectionID will default to 0 if not set through prepareForConnection method
369         Map<String, String> inputs = Collections.singletonMap(CONNECTION_ID, Integer.toString(connectionId));
370
371         invokeAction(CONNECTION_MANAGER, "GetCurrentConnectionInfo", inputs);
372     }
373
374     /**
375      * Invoke GetFeatureList on the UPnP Connection Manager.
376      * Result is received in {@link onValueReceived}.
377      */
378     protected void getFeatureList() {
379         Map<String, String> inputs = Collections.emptyMap();
380
381         invokeAction(CONNECTION_MANAGER, "GetFeatureList", inputs);
382     }
383
384     /**
385      * Invoke GetProtocolInfo on UPnP Connection Manager.
386      * Result is received in {@link onValueReceived}.
387      */
388     protected void getProtocolInfo() {
389         Map<String, String> inputs = Collections.emptyMap();
390
391         invokeAction(CONNECTION_MANAGER, "GetProtocolInfo", inputs);
392     }
393
394     @Override
395     public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
396         logger.debug("UPnP device {} received subscription reply {} from service {}", thing.getLabel(), succeeded,
397                 service);
398         if (!succeeded) {
399             upnpSubscribed = false;
400             String msg = String.format("@text/offline.subscription-failed [ \"%1$s\", \"%2$s\" ]", service,
401                     thing.getLabel());
402             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
403         }
404     }
405
406     @Override
407     public void onStatusChanged(boolean status) {
408         logger.debug("UPnP device {} received status update {}", thing.getLabel(), status);
409         if (status) {
410             initJob();
411         } else {
412             String msg = String.format("@text/offline.communication-lost [ \"%s\" ]", thing.getLabel());
413             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
414         }
415     }
416
417     /**
418      * This method wraps {@link org.openhab.core.io.transport.upnp.UpnpIOService.invokeAction}. It schedules and
419      * submits the call and calls {@link onValueReceived} upon completion. All state updates or other actions depending
420      * on the results should be triggered from {@link onValueReceived} because the class fields with results will be
421      * filled asynchronously.
422      *
423      * @param serviceId
424      * @param actionId
425      * @param inputs
426      */
427     protected void invokeAction(String serviceId, String actionId, Map<String, String> inputs) {
428         upnpScheduler.submit(() -> {
429             Map<String, @Nullable String> result;
430             synchronized (invokeActionLock) {
431                 if (logger.isDebugEnabled() && !"GetPositionInfo".equals(actionId)) {
432                     // don't log position info refresh every second
433                     logger.debug("UPnP device {} invoke upnp action {} on service {} with inputs {}", thing.getLabel(),
434                             actionId, serviceId, inputs);
435                 }
436                 result = upnpIOService.invokeAction(this, serviceId, actionId, inputs);
437                 if (logger.isDebugEnabled() && !"GetPositionInfo".equals(actionId)) {
438                     // don't log position info refresh every second
439                     logger.debug("UPnP device {} invoke upnp action {} on service {} reply {}", thing.getLabel(),
440                             actionId, serviceId, result);
441                 }
442
443                 if (!result.isEmpty()) {
444                     // We can be sure a non-empty result means the device is online.
445                     // An empty result could be expected for certain actions, but could also be hiding an exception.
446                     updateStatus(ThingStatus.ONLINE);
447                 }
448
449                 result = preProcessInvokeActionResult(inputs, serviceId, actionId, result);
450             }
451             for (String variable : result.keySet()) {
452                 onValueReceived(variable, result.get(variable), serviceId);
453             }
454         });
455     }
456
457     /**
458      * Some received values need info on inputs of action. Therefore we allow to pre-process in a separate step. The
459      * method will return an adjusted result list. The default implementation will copy over the received result without
460      * additional processing. Derived classes can add additional logic.
461      *
462      * @param inputs
463      * @param service
464      * @param result
465      * @return
466      */
467     protected Map<String, @Nullable String> preProcessInvokeActionResult(Map<String, String> inputs,
468             @Nullable String service, @Nullable String action, Map<String, @Nullable String> result) {
469         Map<String, @Nullable String> newResult = new HashMap<>();
470         for (String variable : result.keySet()) {
471             String newVariable = preProcessValueReceived(inputs, variable, result.get(variable), service, action);
472             if (newVariable != null) {
473                 newResult.put(newVariable, result.get(variable));
474             }
475         }
476         return newResult;
477     }
478
479     /**
480      * Some received values need info on inputs of action. Therefore we allow to pre-process in a separate step. The
481      * default implementation will return the original value. Derived classes can implement additional logic.
482      *
483      * @param inputs
484      * @param variable
485      * @param value
486      * @param service
487      * @return
488      */
489     protected @Nullable String preProcessValueReceived(Map<String, String> inputs, @Nullable String variable,
490             @Nullable String value, @Nullable String service, @Nullable String action) {
491         return variable;
492     }
493
494     @Override
495     public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
496         if (variable == null || value == null) {
497             return;
498         }
499         switch (variable) {
500             case CONNECTION_ID:
501                 onValueReceivedConnectionId(value);
502                 break;
503             case AV_TRANSPORT_ID:
504                 onValueReceivedAVTransportId(value);
505                 break;
506             case RCS_ID:
507                 onValueReceivedRcsId(value);
508                 break;
509             case "Source":
510             case "Sink":
511                 if (!value.isEmpty()) {
512                     updateProtocolInfo(value);
513                 }
514                 break;
515             default:
516                 break;
517         }
518     }
519
520     private void onValueReceivedConnectionId(@Nullable String value) {
521         try {
522             connectionId = (value == null) ? 0 : Integer.parseInt(value);
523         } catch (NumberFormatException e) {
524             connectionId = 0;
525         }
526         CompletableFuture<Boolean> connectionIdFuture = isConnectionIdSet;
527         if (connectionIdFuture != null) {
528             connectionIdFuture.complete(true);
529         }
530     }
531
532     private void onValueReceivedAVTransportId(@Nullable String value) {
533         try {
534             avTransportId = (value == null) ? 0 : Integer.parseInt(value);
535         } catch (NumberFormatException e) {
536             avTransportId = 0;
537         }
538         CompletableFuture<Boolean> avTransportIdFuture = isAvTransportIdSet;
539         if (avTransportIdFuture != null) {
540             avTransportIdFuture.complete(true);
541         }
542     }
543
544     private void onValueReceivedRcsId(@Nullable String value) {
545         try {
546             rcsId = (value == null) ? 0 : Integer.parseInt(value);
547         } catch (NumberFormatException e) {
548             rcsId = 0;
549         }
550         CompletableFuture<Boolean> rcsIdFuture = isRcsIdSet;
551         if (rcsIdFuture != null) {
552             rcsIdFuture.complete(true);
553         }
554     }
555
556     @Override
557     public @Nullable String getUDN() {
558         return config.udn;
559     }
560
561     protected boolean checkForConnectionIds() {
562         return checkForConnectionId(isConnectionIdSet) & checkForConnectionId(isAvTransportIdSet)
563                 & checkForConnectionId(isRcsIdSet);
564     }
565
566     private boolean checkForConnectionId(@Nullable CompletableFuture<Boolean> future) {
567         try {
568             if (future != null) {
569                 return future.get(config.responseTimeout, TimeUnit.MILLISECONDS);
570             }
571         } catch (InterruptedException | ExecutionException | TimeoutException e) {
572             return false;
573         }
574         return true;
575     }
576
577     /**
578      * Update internal representation of supported protocols, needs to be implemented in derived classes.
579      *
580      * @param value
581      */
582     protected abstract void updateProtocolInfo(String value);
583
584     /**
585      * Subscribe this handler as a participant to a GENA subscription.
586      *
587      * @param serviceId
588      * @param duration
589      */
590     protected void addSubscription(String serviceId, int duration) {
591         if (upnpIOService.isRegistered(this)) {
592             logger.debug("UPnP device {} add upnp subscription on {}", thing.getLabel(), serviceId);
593             upnpIOService.addSubscription(this, serviceId, duration);
594         }
595     }
596
597     /**
598      * Remove this handler from the GENA subscriptions.
599      *
600      * @param serviceId
601      */
602     protected void removeSubscription(String serviceId) {
603         if (upnpIOService.isRegistered(this)) {
604             upnpIOService.removeSubscription(this, serviceId);
605         }
606     }
607
608     protected void addSubscriptions() {
609         upnpSubscribed = true;
610
611         for (String subscription : serviceSubscriptions) {
612             addSubscription(subscription, SUBSCRIPTION_DURATION_SECONDS);
613         }
614         subscriptionRefreshJob = upnpScheduler.scheduleWithFixedDelay(subscriptionRefresh,
615                 SUBSCRIPTION_DURATION_SECONDS / 2, SUBSCRIPTION_DURATION_SECONDS / 2, TimeUnit.SECONDS);
616
617         // This action should exist on all media devices and return a result, so a good candidate for testing the
618         // connection.
619         upnpIOService.addStatusListener(this, CONNECTION_MANAGER, "GetCurrentConnectionIDs", config.refresh);
620     }
621
622     protected void removeSubscriptions() {
623         cancelSubscriptionRefreshJob();
624
625         for (String subscription : serviceSubscriptions) {
626             removeSubscription(subscription);
627         }
628
629         upnpIOService.removeStatusListener(this);
630
631         upnpSubscribed = false;
632     }
633
634     private void cancelSubscriptionRefreshJob() {
635         ScheduledFuture<?> refreshJob = subscriptionRefreshJob;
636
637         if (refreshJob != null) {
638             refreshJob.cancel(true);
639         }
640         subscriptionRefreshJob = null;
641     }
642
643     @Override
644     public abstract void playlistsListChanged();
645
646     /**
647      * Get access to all device info through the UPnP {@link RemoteDevice}.
648      *
649      * @return UPnP RemoteDevice
650      */
651     protected @Nullable RemoteDevice getDevice() {
652         return device;
653     }
654 }