]> git.basschouten.com Git - openhab-addons.git/blob
12b6bcf525009b42781905ff2e90c08fddb58f4e
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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 org.jupnp.registry.RegistryListener RegistryListener} or is updated. Configuration info can be retrieved
232      * from the {@link RemoteDevice}.
233      *
234      * @param device
235      */
236     public void updateDeviceConfig(RemoteDevice device) {
237         this.device = device;
238     };
239
240     protected void updateStateDescription(ChannelUID channelUID, List<StateOption> stateOptionList) {
241         StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false)
242                 .withOptions(stateOptionList).build().toStateDescription();
243         upnpStateDescriptionProvider.setDescription(channelUID, stateDescription);
244     }
245
246     protected void updateCommandDescription(ChannelUID channelUID, List<CommandOption> commandOptionList) {
247         CommandDescription commandDescription = CommandDescriptionBuilder.create().withCommandOptions(commandOptionList)
248                 .build();
249         upnpCommandDescriptionProvider.setDescription(channelUID, commandDescription);
250     }
251
252     protected void createChannel(@Nullable UpnpChannelName upnpChannelName) {
253         if ((upnpChannelName != null)) {
254             createChannel(upnpChannelName.getChannelId(), upnpChannelName.getLabel(), upnpChannelName.getDescription(),
255                     upnpChannelName.getItemType(), upnpChannelName.getChannelType());
256         }
257     }
258
259     protected void createChannel(String channelId, String label, String description, String itemType,
260             String channelType) {
261         ChannelUID channelUID = new ChannelUID(thing.getUID(), channelId);
262
263         if (thing.getChannel(channelUID) != null) {
264             // channel already exists
265             logger.trace("UPnP device {}, channel {} already exists", thing.getLabel(), channelId);
266             return;
267         }
268
269         ChannelTypeUID channelTypeUID = new ChannelTypeUID(channelType);
270         Channel channel = ChannelBuilder.create(channelUID).withLabel(label).withDescription(description)
271                 .withAcceptedItemType(itemType).withType(channelTypeUID).build();
272
273         logger.debug("UPnP device {}, created channel {}", thing.getLabel(), channelId);
274
275         updatedChannels.add(channel);
276         updatedChannelUIDs.add(channelUID);
277         updateChannels = true;
278     }
279
280     protected void updateChannels() {
281         if (updateChannels) {
282             List<Channel> channels = thing.getChannels().stream().filter(c -> !updatedChannelUIDs.contains(c.getUID()))
283                     .collect(Collectors.toList());
284             channels.addAll(updatedChannels);
285             final ThingBuilder thingBuilder = editThing();
286             thingBuilder.withChannels(channels);
287             updateThing(thingBuilder.build());
288         }
289         updatedChannels.clear();
290         updatedChannelUIDs.clear();
291         updateChannels = false;
292     }
293
294     /**
295      * Invoke PrepareForConnection on the UPnP Connection Manager.
296      * Result is received in {@link #onValueReceived}.
297      *
298      * @param remoteProtocolInfo
299      * @param peerConnectionManager
300      * @param peerConnectionId
301      * @param direction
302      */
303     protected void prepareForConnection(String remoteProtocolInfo, String peerConnectionManager, int peerConnectionId,
304             String direction) {
305         CompletableFuture<Boolean> settingConnection = isConnectionIdSet;
306         CompletableFuture<Boolean> settingAVTransport = isAvTransportIdSet;
307         CompletableFuture<Boolean> settingRcs = isRcsIdSet;
308         if (settingConnection != null) {
309             settingConnection.complete(false);
310         }
311         if (settingAVTransport != null) {
312             settingAVTransport.complete(false);
313         }
314         if (settingRcs != null) {
315             settingRcs.complete(false);
316         }
317
318         // Set new futures, so we don't try to use service when connection id's are not known yet
319         isConnectionIdSet = new CompletableFuture<Boolean>();
320         isAvTransportIdSet = new CompletableFuture<Boolean>();
321         isRcsIdSet = new CompletableFuture<Boolean>();
322
323         HashMap<String, String> inputs = new HashMap<String, String>();
324         inputs.put("RemoteProtocolInfo", remoteProtocolInfo);
325         inputs.put("PeerConnectionManager", peerConnectionManager);
326         inputs.put("PeerConnectionID", Integer.toString(peerConnectionId));
327         inputs.put("Direction", direction);
328
329         invokeAction(CONNECTION_MANAGER, "PrepareForConnection", inputs);
330     }
331
332     /**
333      * Invoke ConnectionComplete on UPnP Connection Manager.
334      */
335     protected void connectionComplete() {
336         Map<String, String> inputs = Map.of(CONNECTION_ID, Integer.toString(connectionId));
337
338         invokeAction(CONNECTION_MANAGER, "ConnectionComplete", inputs);
339     }
340
341     /**
342      * Invoke GetCurrentConnectionIDs on the UPnP Connection Manager.
343      * Result is received in {@link #onValueReceived}.
344      */
345     protected void getCurrentConnectionIDs() {
346         Map<String, String> inputs = Collections.emptyMap();
347
348         invokeAction(CONNECTION_MANAGER, "GetCurrentConnectionIDs", inputs);
349     }
350
351     /**
352      * Invoke GetCurrentConnectionInfo on the UPnP Connection Manager.
353      * Result is received in {@link #onValueReceived}.
354      */
355     protected void getCurrentConnectionInfo() {
356         CompletableFuture<Boolean> settingAVTransport = isAvTransportIdSet;
357         CompletableFuture<Boolean> settingRcs = isRcsIdSet;
358         if (settingAVTransport != null) {
359             settingAVTransport.complete(false);
360         }
361         if (settingRcs != null) {
362             settingRcs.complete(false);
363         }
364
365         // Set new futures, so we don't try to use service when connection id's are not known yet
366         isAvTransportIdSet = new CompletableFuture<Boolean>();
367         isRcsIdSet = new CompletableFuture<Boolean>();
368
369         // ConnectionID will default to 0 if not set through prepareForConnection method
370         Map<String, String> inputs = Map.of(CONNECTION_ID, Integer.toString(connectionId));
371
372         invokeAction(CONNECTION_MANAGER, "GetCurrentConnectionInfo", inputs);
373     }
374
375     /**
376      * Invoke GetFeatureList on the UPnP Connection Manager.
377      * Result is received in {@link #onValueReceived}.
378      */
379     protected void getFeatureList() {
380         Map<String, String> inputs = Collections.emptyMap();
381
382         invokeAction(CONNECTION_MANAGER, "GetFeatureList", inputs);
383     }
384
385     /**
386      * Invoke GetProtocolInfo on UPnP Connection Manager.
387      * Result is received in {@link #onValueReceived}.
388      */
389     protected void getProtocolInfo() {
390         Map<String, String> inputs = Collections.emptyMap();
391
392         invokeAction(CONNECTION_MANAGER, "GetProtocolInfo", inputs);
393     }
394
395     @Override
396     public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
397         logger.debug("UPnP device {} received subscription reply {} from service {}", thing.getLabel(), succeeded,
398                 service);
399         if (!succeeded) {
400             upnpSubscribed = false;
401             String msg = String.format("@text/offline.subscription-failed [ \"%1$s\", \"%2$s\" ]", service,
402                     thing.getLabel());
403             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
404         }
405     }
406
407     @Override
408     public void onStatusChanged(boolean status) {
409         logger.debug("UPnP device {} received status update {}", thing.getLabel(), status);
410         if (status) {
411             initJob();
412         } else {
413             String msg = String.format("@text/offline.communication-lost [ \"%s\" ]", thing.getLabel());
414             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
415         }
416     }
417
418     /**
419      * This method wraps {@link org.openhab.core.io.transport.upnp.UpnpIOService#invokeAction invokeAction}. It
420      * schedules and submits the call and calls {@link #onValueReceived} upon completion. All state updates or other
421      * actions depending on the results should be triggered from {@link #onValueReceived} because the class fields with
422      * results will be filled asynchronously.
423      *
424      * @param serviceId
425      * @param actionId
426      * @param inputs
427      */
428     protected void invokeAction(String serviceId, String actionId, Map<String, String> inputs) {
429         upnpScheduler.submit(() -> {
430             Map<String, @Nullable String> result;
431             synchronized (invokeActionLock) {
432                 if (logger.isDebugEnabled() && !"GetPositionInfo".equals(actionId)) {
433                     // don't log position info refresh every second
434                     logger.debug("UPnP device {} invoke upnp action {} on service {} with inputs {}", thing.getLabel(),
435                             actionId, serviceId, inputs);
436                 }
437                 result = upnpIOService.invokeAction(this, serviceId, actionId, inputs);
438                 if (logger.isDebugEnabled() && !"GetPositionInfo".equals(actionId)) {
439                     // don't log position info refresh every second
440                     logger.debug("UPnP device {} invoke upnp action {} on service {} reply {}", thing.getLabel(),
441                             actionId, serviceId, result);
442                 }
443
444                 if (!result.isEmpty()) {
445                     // We can be sure a non-empty result means the device is online.
446                     // An empty result could be expected for certain actions, but could also be hiding an exception.
447                     updateStatus(ThingStatus.ONLINE);
448                 }
449
450                 result = preProcessInvokeActionResult(inputs, serviceId, actionId, result);
451             }
452             for (String variable : result.keySet()) {
453                 onValueReceived(variable, result.get(variable), serviceId);
454             }
455         });
456     }
457
458     /**
459      * Some received values need info on inputs of action. Therefore we allow to pre-process in a separate step. The
460      * method will return an adjusted result list. The default implementation will copy over the received result without
461      * additional processing. Derived classes can add additional logic.
462      *
463      * @param inputs
464      * @param service
465      * @param result
466      * @return
467      */
468     protected Map<String, @Nullable String> preProcessInvokeActionResult(Map<String, String> inputs,
469             @Nullable String service, @Nullable String action, Map<String, @Nullable String> result) {
470         Map<String, @Nullable String> newResult = new HashMap<>();
471         for (String variable : result.keySet()) {
472             String newVariable = preProcessValueReceived(inputs, variable, result.get(variable), service, action);
473             if (newVariable != null) {
474                 newResult.put(newVariable, result.get(variable));
475             }
476         }
477         return newResult;
478     }
479
480     /**
481      * Some received values need info on inputs of action. Therefore we allow to pre-process in a separate step. The
482      * default implementation will return the original value. Derived classes can implement additional logic.
483      *
484      * @param inputs
485      * @param variable
486      * @param value
487      * @param service
488      * @return
489      */
490     protected @Nullable String preProcessValueReceived(Map<String, String> inputs, @Nullable String variable,
491             @Nullable String value, @Nullable String service, @Nullable String action) {
492         return variable;
493     }
494
495     @Override
496     public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
497         if (variable == null || value == null) {
498             return;
499         }
500         switch (variable) {
501             case CONNECTION_ID:
502                 onValueReceivedConnectionId(value);
503                 break;
504             case AV_TRANSPORT_ID:
505                 onValueReceivedAVTransportId(value);
506                 break;
507             case RCS_ID:
508                 onValueReceivedRcsId(value);
509                 break;
510             case "Source":
511             case "Sink":
512                 if (!value.isEmpty()) {
513                     updateProtocolInfo(value);
514                 }
515                 break;
516             default:
517                 break;
518         }
519     }
520
521     private void onValueReceivedConnectionId(@Nullable String value) {
522         try {
523             connectionId = (value == null) ? 0 : Integer.parseInt(value);
524         } catch (NumberFormatException e) {
525             connectionId = 0;
526         }
527         CompletableFuture<Boolean> connectionIdFuture = isConnectionIdSet;
528         if (connectionIdFuture != null) {
529             connectionIdFuture.complete(true);
530         }
531     }
532
533     private void onValueReceivedAVTransportId(@Nullable String value) {
534         try {
535             avTransportId = (value == null) ? 0 : Integer.parseInt(value);
536         } catch (NumberFormatException e) {
537             avTransportId = 0;
538         }
539         CompletableFuture<Boolean> avTransportIdFuture = isAvTransportIdSet;
540         if (avTransportIdFuture != null) {
541             avTransportIdFuture.complete(true);
542         }
543     }
544
545     private void onValueReceivedRcsId(@Nullable String value) {
546         try {
547             rcsId = (value == null) ? 0 : Integer.parseInt(value);
548         } catch (NumberFormatException e) {
549             rcsId = 0;
550         }
551         CompletableFuture<Boolean> rcsIdFuture = isRcsIdSet;
552         if (rcsIdFuture != null) {
553             rcsIdFuture.complete(true);
554         }
555     }
556
557     @Override
558     public @Nullable String getUDN() {
559         return config.udn;
560     }
561
562     protected boolean checkForConnectionIds() {
563         return checkForConnectionId(isConnectionIdSet) & checkForConnectionId(isAvTransportIdSet)
564                 & checkForConnectionId(isRcsIdSet);
565     }
566
567     private boolean checkForConnectionId(@Nullable CompletableFuture<Boolean> future) {
568         try {
569             if (future != null) {
570                 return future.get(config.responseTimeout, TimeUnit.MILLISECONDS);
571             }
572         } catch (InterruptedException | ExecutionException | TimeoutException e) {
573             return false;
574         }
575         return true;
576     }
577
578     /**
579      * Update internal representation of supported protocols, needs to be implemented in derived classes.
580      *
581      * @param value
582      */
583     protected abstract void updateProtocolInfo(String value);
584
585     /**
586      * Subscribe this handler as a participant to a GENA subscription.
587      *
588      * @param serviceId
589      * @param duration
590      */
591     protected void addSubscription(String serviceId, int duration) {
592         if (upnpIOService.isRegistered(this)) {
593             logger.debug("UPnP device {} add upnp subscription on {}", thing.getLabel(), serviceId);
594             upnpIOService.addSubscription(this, serviceId, duration);
595         }
596     }
597
598     /**
599      * Remove this handler from the GENA subscriptions.
600      *
601      * @param serviceId
602      */
603     protected void removeSubscription(String serviceId) {
604         if (upnpIOService.isRegistered(this)) {
605             upnpIOService.removeSubscription(this, serviceId);
606         }
607     }
608
609     protected void addSubscriptions() {
610         upnpSubscribed = true;
611
612         for (String subscription : serviceSubscriptions) {
613             addSubscription(subscription, SUBSCRIPTION_DURATION_SECONDS);
614         }
615         subscriptionRefreshJob = upnpScheduler.scheduleWithFixedDelay(subscriptionRefresh,
616                 SUBSCRIPTION_DURATION_SECONDS / 2, SUBSCRIPTION_DURATION_SECONDS / 2, TimeUnit.SECONDS);
617
618         // This action should exist on all media devices and return a result, so a good candidate for testing the
619         // connection.
620         upnpIOService.addStatusListener(this, CONNECTION_MANAGER, "GetCurrentConnectionIDs", config.refresh);
621     }
622
623     protected void removeSubscriptions() {
624         cancelSubscriptionRefreshJob();
625
626         for (String subscription : serviceSubscriptions) {
627             removeSubscription(subscription);
628         }
629
630         upnpIOService.removeStatusListener(this);
631
632         upnpSubscribed = false;
633     }
634
635     private void cancelSubscriptionRefreshJob() {
636         ScheduledFuture<?> refreshJob = subscriptionRefreshJob;
637
638         if (refreshJob != null) {
639             refreshJob.cancel(true);
640         }
641         subscriptionRefreshJob = null;
642     }
643
644     @Override
645     public abstract void playlistsListChanged();
646
647     /**
648      * Get access to all device info through the UPnP {@link RemoteDevice}.
649      *
650      * @return UPnP RemoteDevice
651      */
652     protected @Nullable RemoteDevice getDevice() {
653         return device;
654     }
655 }