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