2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.upnpcontrol.internal.handler;
15 import java.util.ArrayList;
16 import java.util.Collections;
17 import java.util.HashMap;
18 import java.util.List;
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;
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;
62 * The {@link UpnpHandler} is the base class for {@link UpnpRendererHandler} and {@link UpnpServerHandler}. The base
63 * class implements UPnPConnectionManager service actions.
65 * @author Mark Herwege - Initial contribution
66 * @author Karel Goderis - Based on UPnP logic in Sonos binding
69 public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOParticipant, UpnpPlaylistsListener {
71 private final Logger logger = LoggerFactory.getLogger(UpnpHandler.class);
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("(?:.*):(?:.*):(.*):(?:.*)");
80 protected UpnpIOService upnpIOService;
82 protected volatile @Nullable RemoteDevice device;
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");
87 private boolean updateChannels;
88 private final List<Channel> updatedChannels = new ArrayList<>();
89 private final List<ChannelUID> updatedChannelUIDs = new ArrayList<>();
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
95 protected UpnpControlBindingConfiguration bindingConfig;
96 protected UpnpControlConfiguration config;
98 protected final Object invokeActionLock = new Object();
100 protected @Nullable ScheduledFuture<?> pollingJob;
101 protected final Object jobLock = new Object();
103 protected volatile @Nullable CompletableFuture<Boolean> isConnectionIdSet;
104 protected volatile @Nullable CompletableFuture<Boolean> isAvTransportIdSet;
105 protected volatile @Nullable CompletableFuture<Boolean> isRcsIdSet;
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);
116 protected volatile boolean upnpSubscribed;
118 protected UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
119 protected UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider;
121 public UpnpHandler(Thing thing, UpnpIOService upnpIOService, UpnpControlBindingConfiguration configuration,
122 UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
123 UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider) {
126 this.upnpIOService = upnpIOService;
128 this.bindingConfig = configuration;
130 this.upnpStateDescriptionProvider = upnpStateDescriptionProvider;
131 this.upnpCommandDescriptionProvider = upnpCommandDescriptionProvider;
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);
139 public void initialize() {
140 config = getConfigAs(UpnpControlConfiguration.class);
142 upnpIOService.registerParticipant(this);
144 UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
145 UpnpControlUtil.playlistsSubscribe(this);
149 public void dispose() {
151 removeSubscriptions();
153 UpnpControlUtil.playlistsUnsubscribe(this);
155 CompletableFuture<Boolean> connectionIdFuture = isConnectionIdSet;
156 if (connectionIdFuture != null) {
157 connectionIdFuture.complete(false);
158 isConnectionIdSet = null;
160 CompletableFuture<Boolean> avTransportIdFuture = isAvTransportIdSet;
161 if (avTransportIdFuture != null) {
162 avTransportIdFuture.complete(false);
163 isAvTransportIdSet = null;
165 CompletableFuture<Boolean> rcsIdFuture = isRcsIdSet;
166 if (rcsIdFuture != null) {
167 rcsIdFuture.complete(false);
171 updateChannels = false;
172 updatedChannels.clear();
173 updatedChannelUIDs.clear();
175 upnpIOService.removeStatusListener(this);
176 upnpIOService.unregisterParticipant(this);
179 private void cancelPollingJob() {
180 ScheduledFuture<?> job = pollingJob;
189 * To be called from implementing classes when initializing the device, to start initialization refresh
191 protected void initDevice() {
192 String udn = getUDN();
193 if ((udn != null) && !udn.isEmpty()) {
194 if (config.refresh == 0) {
195 upnpScheduler.submit(this::initJob);
197 pollingJob = upnpScheduler.scheduleWithFixedDelay(this::initJob, 0, config.refresh, TimeUnit.SECONDS);
200 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
201 "No UDN configured for " + thing.getLabel());
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.
210 protected abstract void initJob();
213 protected void updateStatus(ThingStatus status) {
214 ThingStatus currentStatus = thing.getStatus();
216 super.updateStatus(status);
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());
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}.
234 public void updateDeviceConfig(RemoteDevice device) {
235 this.device = device;
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);
244 protected void updateCommandDescription(ChannelUID channelUID, List<CommandOption> commandOptionList) {
245 CommandDescription commandDescription = CommandDescriptionBuilder.create().withCommandOptions(commandOptionList)
247 upnpCommandDescriptionProvider.setDescription(channelUID, commandDescription);
250 protected void createChannel(@Nullable UpnpChannelName upnpChannelName) {
251 if ((upnpChannelName != null)) {
252 createChannel(upnpChannelName.getChannelId(), upnpChannelName.getLabel(), upnpChannelName.getDescription(),
253 upnpChannelName.getItemType(), upnpChannelName.getChannelType());
257 protected void createChannel(String channelId, String label, String description, String itemType,
258 String channelType) {
259 ChannelUID channelUID = new ChannelUID(thing.getUID(), channelId);
261 if (thing.getChannel(channelUID) != null) {
262 // channel already exists
263 logger.trace("UPnP device {}, channel {} already exists", thing.getLabel(), channelId);
267 ChannelTypeUID channelTypeUID = new ChannelTypeUID(channelType);
268 Channel channel = ChannelBuilder.create(channelUID).withLabel(label).withDescription(description)
269 .withAcceptedItemType(itemType).withType(channelTypeUID).build();
271 logger.debug("UPnP device {}, created channel {}", thing.getLabel(), channelId);
273 updatedChannels.add(channel);
274 updatedChannelUIDs.add(channelUID);
275 updateChannels = true;
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());
287 updatedChannels.clear();
288 updatedChannelUIDs.clear();
289 updateChannels = false;
293 * Invoke PrepareForConnection on the UPnP Connection Manager.
294 * Result is received in {@link onValueReceived}.
296 * @param remoteProtocolInfo
297 * @param peerConnectionManager
298 * @param peerConnectionId
301 protected void prepareForConnection(String remoteProtocolInfo, String peerConnectionManager, int peerConnectionId,
303 CompletableFuture<Boolean> settingConnection = isConnectionIdSet;
304 CompletableFuture<Boolean> settingAVTransport = isAvTransportIdSet;
305 CompletableFuture<Boolean> settingRcs = isRcsIdSet;
306 if (settingConnection != null) {
307 settingConnection.complete(false);
309 if (settingAVTransport != null) {
310 settingAVTransport.complete(false);
312 if (settingRcs != null) {
313 settingRcs.complete(false);
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>();
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);
327 invokeAction(CONNECTION_MANAGER, "PrepareForConnection", inputs);
331 * Invoke ConnectionComplete on UPnP Connection Manager.
333 protected void connectionComplete() {
334 Map<String, String> inputs = Collections.singletonMap(CONNECTION_ID, Integer.toString(connectionId));
336 invokeAction(CONNECTION_MANAGER, "ConnectionComplete", inputs);
340 * Invoke GetCurrentConnectionIDs on the UPnP Connection Manager.
341 * Result is received in {@link onValueReceived}.
343 protected void getCurrentConnectionIDs() {
344 Map<String, String> inputs = Collections.emptyMap();
346 invokeAction(CONNECTION_MANAGER, "GetCurrentConnectionIDs", inputs);
350 * Invoke GetCurrentConnectionInfo on the UPnP Connection Manager.
351 * Result is received in {@link onValueReceived}.
353 protected void getCurrentConnectionInfo() {
354 CompletableFuture<Boolean> settingAVTransport = isAvTransportIdSet;
355 CompletableFuture<Boolean> settingRcs = isRcsIdSet;
356 if (settingAVTransport != null) {
357 settingAVTransport.complete(false);
359 if (settingRcs != null) {
360 settingRcs.complete(false);
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>();
367 // ConnectionID will default to 0 if not set through prepareForConnection method
368 Map<String, String> inputs = Collections.singletonMap(CONNECTION_ID, Integer.toString(connectionId));
370 invokeAction(CONNECTION_MANAGER, "GetCurrentConnectionInfo", inputs);
374 * Invoke GetFeatureList on the UPnP Connection Manager.
375 * Result is received in {@link onValueReceived}.
377 protected void getFeatureList() {
378 Map<String, String> inputs = Collections.emptyMap();
380 invokeAction(CONNECTION_MANAGER, "GetFeatureList", inputs);
384 * Invoke GetProtocolInfo on UPnP Connection Manager.
385 * Result is received in {@link onValueReceived}.
387 protected void getProtocolInfo() {
388 Map<String, String> inputs = Collections.emptyMap();
390 invokeAction(CONNECTION_MANAGER, "GetProtocolInfo", inputs);
394 public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
395 logger.debug("UPnP device {} received subscription reply {} from service {}", thing.getLabel(), succeeded,
398 upnpSubscribed = false;
399 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
400 "Could not subscribe to service " + service + "for" + thing.getLabel());
405 public void onStatusChanged(boolean status) {
406 logger.debug("UPnP device {} received status update {}", thing.getLabel(), status);
410 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
411 "Communication lost with " + thing.getLabel());
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.
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);
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);
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);
447 result = preProcessInvokeActionResult(inputs, serviceId, actionId, result);
449 for (String variable : result.keySet()) {
450 onValueReceived(variable, result.get(variable), serviceId);
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.
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));
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.
487 protected @Nullable String preProcessValueReceived(Map<String, String> inputs, @Nullable String variable,
488 @Nullable String value, @Nullable String service, @Nullable String action) {
493 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
494 if (variable == null || value == null) {
499 onValueReceivedConnectionId(value);
501 case AV_TRANSPORT_ID:
502 onValueReceivedAVTransportId(value);
505 onValueReceivedRcsId(value);
509 if (!value.isEmpty()) {
510 updateProtocolInfo(value);
518 private void onValueReceivedConnectionId(@Nullable String value) {
520 connectionId = (value == null) ? 0 : Integer.parseInt(value);
521 } catch (NumberFormatException e) {
524 CompletableFuture<Boolean> connectionIdFuture = isConnectionIdSet;
525 if (connectionIdFuture != null) {
526 connectionIdFuture.complete(true);
530 private void onValueReceivedAVTransportId(@Nullable String value) {
532 avTransportId = (value == null) ? 0 : Integer.parseInt(value);
533 } catch (NumberFormatException e) {
536 CompletableFuture<Boolean> avTransportIdFuture = isAvTransportIdSet;
537 if (avTransportIdFuture != null) {
538 avTransportIdFuture.complete(true);
542 private void onValueReceivedRcsId(@Nullable String value) {
544 rcsId = (value == null) ? 0 : Integer.parseInt(value);
545 } catch (NumberFormatException e) {
548 CompletableFuture<Boolean> rcsIdFuture = isRcsIdSet;
549 if (rcsIdFuture != null) {
550 rcsIdFuture.complete(true);
555 public @Nullable String getUDN() {
559 protected boolean checkForConnectionIds() {
560 return checkForConnectionId(isConnectionIdSet) & checkForConnectionId(isAvTransportIdSet)
561 & checkForConnectionId(isRcsIdSet);
564 private boolean checkForConnectionId(@Nullable CompletableFuture<Boolean> future) {
566 if (future != null) {
567 return future.get(config.responseTimeout, TimeUnit.MILLISECONDS);
569 } catch (InterruptedException | ExecutionException | TimeoutException e) {
576 * Update internal representation of supported protocols, needs to be implemented in derived classes.
580 protected abstract void updateProtocolInfo(String value);
583 * Subscribe this handler as a participant to a GENA subscription.
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);
596 * Remove this handler from the GENA subscriptions.
600 protected void removeSubscription(String serviceId) {
601 if (upnpIOService.isRegistered(this)) {
602 upnpIOService.removeSubscription(this, serviceId);
606 protected void addSubscriptions() {
607 upnpSubscribed = true;
609 for (String subscription : serviceSubscriptions) {
610 addSubscription(subscription, SUBSCRIPTION_DURATION_SECONDS);
612 subscriptionRefreshJob = upnpScheduler.scheduleWithFixedDelay(subscriptionRefresh,
613 SUBSCRIPTION_DURATION_SECONDS / 2, SUBSCRIPTION_DURATION_SECONDS / 2, TimeUnit.SECONDS);
615 // This action should exist on all media devices and return a result, so a good candidate for testing the
617 upnpIOService.addStatusListener(this, CONNECTION_MANAGER, "GetCurrentConnectionIDs", config.refresh);
620 protected void removeSubscriptions() {
621 cancelSubscriptionRefreshJob();
623 for (String subscription : serviceSubscriptions) {
624 removeSubscription(subscription);
627 upnpIOService.removeStatusListener(this);
629 upnpSubscribed = false;
632 private void cancelSubscriptionRefreshJob() {
633 ScheduledFuture<?> refreshJob = subscriptionRefreshJob;
635 if (refreshJob != null) {
636 refreshJob.cancel(true);
638 subscriptionRefreshJob = null;
642 public abstract void playlistsListChanged();
645 * Get access to all device info through the UPnP {@link RemoteDevice}.
647 * @return UPnP RemoteDevice
649 protected @Nullable RemoteDevice getDevice() {