2 * Copyright (c) 2010-2023 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 updateStatus(ThingStatus.UNKNOWN);
196 if (config.refresh == 0) {
197 upnpScheduler.submit(this::initJob);
199 pollingJob = upnpScheduler.scheduleWithFixedDelay(this::initJob, 0, config.refresh, TimeUnit.SECONDS);
202 String msg = String.format("@text/offline.no-udn [ \"%s\" ]", thing.getLabel());
203 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
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.
212 protected abstract void initJob();
215 protected void updateStatus(ThingStatus status) {
216 ThingStatus currentStatus = thing.getStatus();
218 super.updateStatus(status);
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());
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}.
236 public void updateDeviceConfig(RemoteDevice device) {
237 this.device = device;
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);
246 protected void updateCommandDescription(ChannelUID channelUID, List<CommandOption> commandOptionList) {
247 CommandDescription commandDescription = CommandDescriptionBuilder.create().withCommandOptions(commandOptionList)
249 upnpCommandDescriptionProvider.setDescription(channelUID, commandDescription);
252 protected void createChannel(@Nullable UpnpChannelName upnpChannelName) {
253 if ((upnpChannelName != null)) {
254 createChannel(upnpChannelName.getChannelId(), upnpChannelName.getLabel(), upnpChannelName.getDescription(),
255 upnpChannelName.getItemType(), upnpChannelName.getChannelType());
259 protected void createChannel(String channelId, String label, String description, String itemType,
260 String channelType) {
261 ChannelUID channelUID = new ChannelUID(thing.getUID(), channelId);
263 if (thing.getChannel(channelUID) != null) {
264 // channel already exists
265 logger.trace("UPnP device {}, channel {} already exists", thing.getLabel(), channelId);
269 ChannelTypeUID channelTypeUID = new ChannelTypeUID(channelType);
270 Channel channel = ChannelBuilder.create(channelUID).withLabel(label).withDescription(description)
271 .withAcceptedItemType(itemType).withType(channelTypeUID).build();
273 logger.debug("UPnP device {}, created channel {}", thing.getLabel(), channelId);
275 updatedChannels.add(channel);
276 updatedChannelUIDs.add(channelUID);
277 updateChannels = true;
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());
289 updatedChannels.clear();
290 updatedChannelUIDs.clear();
291 updateChannels = false;
295 * Invoke PrepareForConnection on the UPnP Connection Manager.
296 * Result is received in {@link onValueReceived}.
298 * @param remoteProtocolInfo
299 * @param peerConnectionManager
300 * @param peerConnectionId
303 protected void prepareForConnection(String remoteProtocolInfo, String peerConnectionManager, int peerConnectionId,
305 CompletableFuture<Boolean> settingConnection = isConnectionIdSet;
306 CompletableFuture<Boolean> settingAVTransport = isAvTransportIdSet;
307 CompletableFuture<Boolean> settingRcs = isRcsIdSet;
308 if (settingConnection != null) {
309 settingConnection.complete(false);
311 if (settingAVTransport != null) {
312 settingAVTransport.complete(false);
314 if (settingRcs != null) {
315 settingRcs.complete(false);
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>();
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);
329 invokeAction(CONNECTION_MANAGER, "PrepareForConnection", inputs);
333 * Invoke ConnectionComplete on UPnP Connection Manager.
335 protected void connectionComplete() {
336 Map<String, String> inputs = Collections.singletonMap(CONNECTION_ID, Integer.toString(connectionId));
338 invokeAction(CONNECTION_MANAGER, "ConnectionComplete", inputs);
342 * Invoke GetCurrentConnectionIDs on the UPnP Connection Manager.
343 * Result is received in {@link onValueReceived}.
345 protected void getCurrentConnectionIDs() {
346 Map<String, String> inputs = Collections.emptyMap();
348 invokeAction(CONNECTION_MANAGER, "GetCurrentConnectionIDs", inputs);
352 * Invoke GetCurrentConnectionInfo on the UPnP Connection Manager.
353 * Result is received in {@link onValueReceived}.
355 protected void getCurrentConnectionInfo() {
356 CompletableFuture<Boolean> settingAVTransport = isAvTransportIdSet;
357 CompletableFuture<Boolean> settingRcs = isRcsIdSet;
358 if (settingAVTransport != null) {
359 settingAVTransport.complete(false);
361 if (settingRcs != null) {
362 settingRcs.complete(false);
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>();
369 // ConnectionID will default to 0 if not set through prepareForConnection method
370 Map<String, String> inputs = Collections.singletonMap(CONNECTION_ID, Integer.toString(connectionId));
372 invokeAction(CONNECTION_MANAGER, "GetCurrentConnectionInfo", inputs);
376 * Invoke GetFeatureList on the UPnP Connection Manager.
377 * Result is received in {@link onValueReceived}.
379 protected void getFeatureList() {
380 Map<String, String> inputs = Collections.emptyMap();
382 invokeAction(CONNECTION_MANAGER, "GetFeatureList", inputs);
386 * Invoke GetProtocolInfo on UPnP Connection Manager.
387 * Result is received in {@link onValueReceived}.
389 protected void getProtocolInfo() {
390 Map<String, String> inputs = Collections.emptyMap();
392 invokeAction(CONNECTION_MANAGER, "GetProtocolInfo", inputs);
396 public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
397 logger.debug("UPnP device {} received subscription reply {} from service {}", thing.getLabel(), succeeded,
400 upnpSubscribed = false;
401 String msg = String.format("@text/offline.subscription-failed [ \"%1$s\", \"%2$s\" ]", service,
403 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
408 public void onStatusChanged(boolean status) {
409 logger.debug("UPnP device {} received status update {}", thing.getLabel(), status);
413 String msg = String.format("@text/offline.communication-lost [ \"%s\" ]", thing.getLabel());
414 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
419 * This method wraps {@link org.openhab.core.io.transport.upnp.UpnpIOService.invokeAction}. It schedules and
420 * submits the call and calls {@link onValueReceived} upon completion. All state updates or other actions depending
421 * on the results should be triggered from {@link onValueReceived} because the class fields with results will be
422 * filled asynchronously.
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);
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);
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);
450 result = preProcessInvokeActionResult(inputs, serviceId, actionId, result);
452 for (String variable : result.keySet()) {
453 onValueReceived(variable, result.get(variable), serviceId);
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.
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));
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.
490 protected @Nullable String preProcessValueReceived(Map<String, String> inputs, @Nullable String variable,
491 @Nullable String value, @Nullable String service, @Nullable String action) {
496 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
497 if (variable == null || value == null) {
502 onValueReceivedConnectionId(value);
504 case AV_TRANSPORT_ID:
505 onValueReceivedAVTransportId(value);
508 onValueReceivedRcsId(value);
512 if (!value.isEmpty()) {
513 updateProtocolInfo(value);
521 private void onValueReceivedConnectionId(@Nullable String value) {
523 connectionId = (value == null) ? 0 : Integer.parseInt(value);
524 } catch (NumberFormatException e) {
527 CompletableFuture<Boolean> connectionIdFuture = isConnectionIdSet;
528 if (connectionIdFuture != null) {
529 connectionIdFuture.complete(true);
533 private void onValueReceivedAVTransportId(@Nullable String value) {
535 avTransportId = (value == null) ? 0 : Integer.parseInt(value);
536 } catch (NumberFormatException e) {
539 CompletableFuture<Boolean> avTransportIdFuture = isAvTransportIdSet;
540 if (avTransportIdFuture != null) {
541 avTransportIdFuture.complete(true);
545 private void onValueReceivedRcsId(@Nullable String value) {
547 rcsId = (value == null) ? 0 : Integer.parseInt(value);
548 } catch (NumberFormatException e) {
551 CompletableFuture<Boolean> rcsIdFuture = isRcsIdSet;
552 if (rcsIdFuture != null) {
553 rcsIdFuture.complete(true);
558 public @Nullable String getUDN() {
562 protected boolean checkForConnectionIds() {
563 return checkForConnectionId(isConnectionIdSet) & checkForConnectionId(isAvTransportIdSet)
564 & checkForConnectionId(isRcsIdSet);
567 private boolean checkForConnectionId(@Nullable CompletableFuture<Boolean> future) {
569 if (future != null) {
570 return future.get(config.responseTimeout, TimeUnit.MILLISECONDS);
572 } catch (InterruptedException | ExecutionException | TimeoutException e) {
579 * Update internal representation of supported protocols, needs to be implemented in derived classes.
583 protected abstract void updateProtocolInfo(String value);
586 * Subscribe this handler as a participant to a GENA subscription.
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);
599 * Remove this handler from the GENA subscriptions.
603 protected void removeSubscription(String serviceId) {
604 if (upnpIOService.isRegistered(this)) {
605 upnpIOService.removeSubscription(this, serviceId);
609 protected void addSubscriptions() {
610 upnpSubscribed = true;
612 for (String subscription : serviceSubscriptions) {
613 addSubscription(subscription, SUBSCRIPTION_DURATION_SECONDS);
615 subscriptionRefreshJob = upnpScheduler.scheduleWithFixedDelay(subscriptionRefresh,
616 SUBSCRIPTION_DURATION_SECONDS / 2, SUBSCRIPTION_DURATION_SECONDS / 2, TimeUnit.SECONDS);
618 // This action should exist on all media devices and return a result, so a good candidate for testing the
620 upnpIOService.addStatusListener(this, CONNECTION_MANAGER, "GetCurrentConnectionIDs", config.refresh);
623 protected void removeSubscriptions() {
624 cancelSubscriptionRefreshJob();
626 for (String subscription : serviceSubscriptions) {
627 removeSubscription(subscription);
630 upnpIOService.removeStatusListener(this);
632 upnpSubscribed = false;
635 private void cancelSubscriptionRefreshJob() {
636 ScheduledFuture<?> refreshJob = subscriptionRefreshJob;
638 if (refreshJob != null) {
639 refreshJob.cancel(true);
641 subscriptionRefreshJob = null;
645 public abstract void playlistsListChanged();
648 * Get access to all device info through the UPnP {@link RemoteDevice}.
650 * @return UPnP RemoteDevice
652 protected @Nullable RemoteDevice getDevice() {