]> git.basschouten.com Git - openhab-addons.git/blob
0c3f3cc1eb6ee42f295fdf3e6a93d96b31ae79ea
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 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.jupnp.registry.RegistryListener;
33 import org.openhab.binding.upnpcontrol.internal.UpnpChannelName;
34 import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
35 import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
36 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
37 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlConfiguration;
38 import org.openhab.binding.upnpcontrol.internal.queue.UpnpPlaylistsListener;
39 import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
40 import org.openhab.core.common.ThreadPoolManager;
41 import org.openhab.core.io.transport.upnp.UpnpIOParticipant;
42 import org.openhab.core.io.transport.upnp.UpnpIOService;
43 import org.openhab.core.thing.Channel;
44 import org.openhab.core.thing.ChannelUID;
45 import org.openhab.core.thing.Thing;
46 import org.openhab.core.thing.ThingStatus;
47 import org.openhab.core.thing.ThingStatusDetail;
48 import org.openhab.core.thing.binding.BaseThingHandler;
49 import org.openhab.core.thing.binding.builder.ChannelBuilder;
50 import org.openhab.core.thing.binding.builder.ThingBuilder;
51 import org.openhab.core.thing.type.ChannelTypeUID;
52 import org.openhab.core.types.CommandDescription;
53 import org.openhab.core.types.CommandDescriptionBuilder;
54 import org.openhab.core.types.CommandOption;
55 import org.openhab.core.types.StateDescription;
56 import org.openhab.core.types.StateDescriptionFragmentBuilder;
57 import org.openhab.core.types.StateOption;
58 import org.slf4j.Logger;
59 import org.slf4j.LoggerFactory;
60
61 /**
62  * The {@link UpnpHandler} is the base class for {@link UpnpRendererHandler} and {@link UpnpServerHandler}. The base
63  * class implements UPnPConnectionManager service actions.
64  *
65  * @author Mark Herwege - Initial contribution
66  * @author Karel Goderis - Based on UPnP logic in Sonos binding
67  */
68 @NonNullByDefault
69 public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOParticipant, UpnpPlaylistsListener {
70
71     private final Logger logger = LoggerFactory.getLogger(UpnpHandler.class);
72
73     // UPnP constants
74     static final String CONNECTION_MANAGER = "ConnectionManager";
75     static final String CONNECTION_ID = "ConnectionID";
76     static final String AV_TRANSPORT_ID = "AVTransportID";
77     static final String RCS_ID = "RcsID";
78     static final Pattern PROTOCOL_PATTERN = Pattern.compile("(?:.*):(?:.*):(.*):(?:.*)");
79
80     protected UpnpIOService upnpIOService;
81
82     protected volatile @Nullable RemoteDevice device;
83
84     // The handlers can potentially create an important number of tasks, therefore put them in a separate thread pool
85     protected ScheduledExecutorService upnpScheduler = ThreadPoolManager.getScheduledPool("binding-upnpcontrol");
86
87     private boolean updateChannels;
88     private final List<Channel> updatedChannels = new ArrayList<>();
89     private final List<ChannelUID> updatedChannelUIDs = new ArrayList<>();
90
91     protected volatile int connectionId = 0; // UPnP Connection Id
92     protected volatile int avTransportId = 0; // UPnP AVTtransport Id
93     protected volatile int rcsId = 0; // UPnP Rendering Control Id
94
95     protected UpnpControlBindingConfiguration bindingConfig;
96     protected UpnpControlConfiguration config;
97
98     protected final Object invokeActionLock = new Object();
99
100     protected @Nullable ScheduledFuture<?> pollingJob;
101     protected final Object jobLock = new Object();
102
103     protected volatile @Nullable CompletableFuture<Boolean> isConnectionIdSet;
104     protected volatile @Nullable CompletableFuture<Boolean> isAvTransportIdSet;
105     protected volatile @Nullable CompletableFuture<Boolean> isRcsIdSet;
106
107     protected static final int SUBSCRIPTION_DURATION_SECONDS = 3600;
108     protected List<String> serviceSubscriptions = new ArrayList<>();
109     protected volatile @Nullable ScheduledFuture<?> subscriptionRefreshJob;
110     protected final Runnable subscriptionRefresh = () -> {
111         for (String subscription : serviceSubscriptions) {
112             removeSubscription(subscription);
113             addSubscription(subscription, SUBSCRIPTION_DURATION_SECONDS);
114         }
115     };
116     protected volatile boolean upnpSubscribed;
117
118     protected UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
119     protected UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider;
120
121     public UpnpHandler(Thing thing, UpnpIOService upnpIOService, UpnpControlBindingConfiguration configuration,
122             UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
123             UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider) {
124         super(thing);
125
126         this.upnpIOService = upnpIOService;
127
128         this.bindingConfig = configuration;
129
130         this.upnpStateDescriptionProvider = upnpStateDescriptionProvider;
131         this.upnpCommandDescriptionProvider = upnpCommandDescriptionProvider;
132
133         // Get this in constructor, so the UDN is immediately available from the config. The concrete classes should
134         // update the config from the initialize method.
135         config = getConfigAs(UpnpControlConfiguration.class);
136     }
137
138     @Override
139     public void initialize() {
140         config = getConfigAs(UpnpControlConfiguration.class);
141
142         upnpIOService.registerParticipant(this);
143
144         UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
145         UpnpControlUtil.playlistsSubscribe(this);
146     }
147
148     @Override
149     public void dispose() {
150         cancelPollingJob();
151         removeSubscriptions();
152
153         UpnpControlUtil.playlistsUnsubscribe(this);
154
155         CompletableFuture<Boolean> connectionIdFuture = isConnectionIdSet;
156         if (connectionIdFuture != null) {
157             connectionIdFuture.complete(false);
158             isConnectionIdSet = null;
159         }
160         CompletableFuture<Boolean> avTransportIdFuture = isAvTransportIdSet;
161         if (avTransportIdFuture != null) {
162             avTransportIdFuture.complete(false);
163             isAvTransportIdSet = null;
164         }
165         CompletableFuture<Boolean> rcsIdFuture = isRcsIdSet;
166         if (rcsIdFuture != null) {
167             rcsIdFuture.complete(false);
168             isRcsIdSet = null;
169         }
170
171         updateChannels = false;
172         updatedChannels.clear();
173         updatedChannelUIDs.clear();
174
175         upnpIOService.removeStatusListener(this);
176         upnpIOService.unregisterParticipant(this);
177     }
178
179     private void cancelPollingJob() {
180         ScheduledFuture<?> job = pollingJob;
181
182         if (job != null) {
183             job.cancel(true);
184         }
185         pollingJob = null;
186     }
187
188     /**
189      * To be called from implementing classes when initializing the device, to start initialization refresh
190      */
191     protected void initDevice() {
192         String udn = getUDN();
193         if ((udn != null) && !udn.isEmpty()) {
194             updateStatus(ThingStatus.UNKNOWN);
195
196             if (config.refresh == 0) {
197                 upnpScheduler.submit(this::initJob);
198             } else {
199                 pollingJob = upnpScheduler.scheduleWithFixedDelay(this::initJob, 0, config.refresh, TimeUnit.SECONDS);
200             }
201         } else {
202             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
203                     "No UDN configured for " + thing.getLabel());
204         }
205     }
206
207     /**
208      * Job to be executed in an asynchronous process when initializing a device. This checks if the connection id's are
209      * correctly set up for the connection. It can also be called from a polling job to get the thing back online when
210      * connection is lost.
211      */
212     protected abstract void initJob();
213
214     @Override
215     protected void updateStatus(ThingStatus status) {
216         ThingStatus currentStatus = thing.getStatus();
217
218         super.updateStatus(status);
219
220         // When status changes to ThingStatus.ONLINE, make sure to refresh all linked channels
221         if (!status.equals(currentStatus) && status.equals(ThingStatus.ONLINE)) {
222             thing.getChannels().forEach(channel -> {
223                 if (isLinked(channel.getUID())) {
224                     channelLinked(channel.getUID());
225                 }
226             });
227         }
228     }
229
230     /**
231      * Method called when a the remote device represented by the thing for this handler is added to the jupnp
232      * {@link RegistryListener} or is updated. Configuration info can be retrieved 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 = Collections.singletonMap(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 = Collections.singletonMap(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             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
402                     "Could not subscribe to service " + service + "for" + thing.getLabel());
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             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
413                     "Communication lost with " + thing.getLabel());
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 }