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.openhab.binding.upnpcontrol.internal.UpnpChannelName;
33 import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
34 import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
35 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
36 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlConfiguration;
37 import org.openhab.binding.upnpcontrol.internal.queue.UpnpPlaylistsListener;
38 import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
39 import org.openhab.core.common.ThreadPoolManager;
40 import org.openhab.core.io.transport.upnp.UpnpIOParticipant;
41 import org.openhab.core.io.transport.upnp.UpnpIOService;
42 import org.openhab.core.thing.Channel;
43 import org.openhab.core.thing.ChannelUID;
44 import org.openhab.core.thing.Thing;
45 import org.openhab.core.thing.ThingStatus;
46 import org.openhab.core.thing.ThingStatusDetail;
47 import org.openhab.core.thing.binding.BaseThingHandler;
48 import org.openhab.core.thing.binding.builder.ChannelBuilder;
49 import org.openhab.core.thing.binding.builder.ThingBuilder;
50 import org.openhab.core.thing.type.ChannelTypeUID;
51 import org.openhab.core.types.CommandDescription;
52 import org.openhab.core.types.CommandDescriptionBuilder;
53 import org.openhab.core.types.CommandOption;
54 import org.openhab.core.types.StateDescription;
55 import org.openhab.core.types.StateDescriptionFragmentBuilder;
56 import org.openhab.core.types.StateOption;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
61 * The {@link UpnpHandler} is the base class for {@link UpnpRendererHandler} and {@link UpnpServerHandler}. The base
62 * class implements UPnPConnectionManager service actions.
64 * @author Mark Herwege - Initial contribution
65 * @author Karel Goderis - Based on UPnP logic in Sonos binding
68 public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOParticipant, UpnpPlaylistsListener {
70 private final Logger logger = LoggerFactory.getLogger(UpnpHandler.class);
73 static final String CONNECTION_MANAGER = "ConnectionManager";
74 static final String CONNECTION_ID = "ConnectionID";
75 static final String AV_TRANSPORT_ID = "AVTransportID";
76 static final String RCS_ID = "RcsID";
77 static final Pattern PROTOCOL_PATTERN = Pattern.compile("(?:.*):(?:.*):(.*):(?:.*)");
79 protected UpnpIOService upnpIOService;
81 protected volatile @Nullable RemoteDevice device;
83 // The handlers can potentially create an important number of tasks, therefore put them in a separate thread pool
84 protected ScheduledExecutorService upnpScheduler = ThreadPoolManager.getScheduledPool("binding-upnpcontrol");
86 private boolean updateChannels;
87 private final List<Channel> updatedChannels = new ArrayList<>();
88 private final List<ChannelUID> updatedChannelUIDs = new ArrayList<>();
90 protected volatile int connectionId = 0; // UPnP Connection Id
91 protected volatile int avTransportId = 0; // UPnP AVTtransport Id
92 protected volatile int rcsId = 0; // UPnP Rendering Control Id
94 protected UpnpControlBindingConfiguration bindingConfig;
95 protected UpnpControlConfiguration config;
97 protected final Object invokeActionLock = new Object();
99 protected @Nullable ScheduledFuture<?> pollingJob;
100 protected final Object jobLock = new Object();
102 protected volatile @Nullable CompletableFuture<Boolean> isConnectionIdSet;
103 protected volatile @Nullable CompletableFuture<Boolean> isAvTransportIdSet;
104 protected volatile @Nullable CompletableFuture<Boolean> isRcsIdSet;
106 protected static final int SUBSCRIPTION_DURATION_SECONDS = 3600;
107 protected List<String> serviceSubscriptions = new ArrayList<>();
108 protected volatile @Nullable ScheduledFuture<?> subscriptionRefreshJob;
109 protected final Runnable subscriptionRefresh = () -> {
110 for (String subscription : serviceSubscriptions) {
111 removeSubscription(subscription);
112 addSubscription(subscription, SUBSCRIPTION_DURATION_SECONDS);
115 protected volatile boolean upnpSubscribed;
117 protected UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
118 protected UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider;
120 public UpnpHandler(Thing thing, UpnpIOService upnpIOService, UpnpControlBindingConfiguration configuration,
121 UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
122 UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider) {
125 this.upnpIOService = upnpIOService;
127 this.bindingConfig = configuration;
129 this.upnpStateDescriptionProvider = upnpStateDescriptionProvider;
130 this.upnpCommandDescriptionProvider = upnpCommandDescriptionProvider;
132 // Get this in constructor, so the UDN is immediately available from the config. The concrete classes should
133 // update the config from the initialize method.
134 config = getConfigAs(UpnpControlConfiguration.class);
138 public void initialize() {
139 config = getConfigAs(UpnpControlConfiguration.class);
141 upnpIOService.registerParticipant(this);
143 UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
144 UpnpControlUtil.playlistsSubscribe(this);
148 public void dispose() {
150 removeSubscriptions();
152 UpnpControlUtil.playlistsUnsubscribe(this);
154 CompletableFuture<Boolean> connectionIdFuture = isConnectionIdSet;
155 if (connectionIdFuture != null) {
156 connectionIdFuture.complete(false);
157 isConnectionIdSet = null;
159 CompletableFuture<Boolean> avTransportIdFuture = isAvTransportIdSet;
160 if (avTransportIdFuture != null) {
161 avTransportIdFuture.complete(false);
162 isAvTransportIdSet = null;
164 CompletableFuture<Boolean> rcsIdFuture = isRcsIdSet;
165 if (rcsIdFuture != null) {
166 rcsIdFuture.complete(false);
170 updateChannels = false;
171 updatedChannels.clear();
172 updatedChannelUIDs.clear();
174 upnpIOService.removeStatusListener(this);
175 upnpIOService.unregisterParticipant(this);
178 private void cancelPollingJob() {
179 ScheduledFuture<?> job = pollingJob;
188 * To be called from implementing classes when initializing the device, to start initialization refresh
190 protected void initDevice() {
191 String udn = getUDN();
192 if ((udn != null) && !udn.isEmpty()) {
193 updateStatus(ThingStatus.UNKNOWN);
195 if (config.refresh == 0) {
196 upnpScheduler.submit(this::initJob);
198 pollingJob = upnpScheduler.scheduleWithFixedDelay(this::initJob, 0, config.refresh, TimeUnit.SECONDS);
201 String msg = String.format("@text/offline.no-udn [ \"%s\" ]", thing.getLabel());
202 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
207 * Job to be executed in an asynchronous process when initializing a device. This checks if the connection id's are
208 * correctly set up for the connection. It can also be called from a polling job to get the thing back online when
209 * connection is lost.
211 protected abstract void initJob();
214 protected void updateStatus(ThingStatus status) {
215 ThingStatus currentStatus = thing.getStatus();
217 super.updateStatus(status);
219 // When status changes to ThingStatus.ONLINE, make sure to refresh all linked channels
220 if (!status.equals(currentStatus) && status.equals(ThingStatus.ONLINE)) {
221 thing.getChannels().forEach(channel -> {
222 if (isLinked(channel.getUID())) {
223 channelLinked(channel.getUID());
230 * Method called when a the remote device represented by the thing for this handler is added to the jupnp
231 * {@link RegistryListener} or is updated. Configuration info can be retrieved from the {@link RemoteDevice}.
235 public void updateDeviceConfig(RemoteDevice device) {
236 this.device = device;
239 protected void updateStateDescription(ChannelUID channelUID, List<StateOption> stateOptionList) {
240 StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false)
241 .withOptions(stateOptionList).build().toStateDescription();
242 upnpStateDescriptionProvider.setDescription(channelUID, stateDescription);
245 protected void updateCommandDescription(ChannelUID channelUID, List<CommandOption> commandOptionList) {
246 CommandDescription commandDescription = CommandDescriptionBuilder.create().withCommandOptions(commandOptionList)
248 upnpCommandDescriptionProvider.setDescription(channelUID, commandDescription);
251 protected void createChannel(@Nullable UpnpChannelName upnpChannelName) {
252 if ((upnpChannelName != null)) {
253 createChannel(upnpChannelName.getChannelId(), upnpChannelName.getLabel(), upnpChannelName.getDescription(),
254 upnpChannelName.getItemType(), upnpChannelName.getChannelType());
258 protected void createChannel(String channelId, String label, String description, String itemType,
259 String channelType) {
260 ChannelUID channelUID = new ChannelUID(thing.getUID(), channelId);
262 if (thing.getChannel(channelUID) != null) {
263 // channel already exists
264 logger.trace("UPnP device {}, channel {} already exists", thing.getLabel(), channelId);
268 ChannelTypeUID channelTypeUID = new ChannelTypeUID(channelType);
269 Channel channel = ChannelBuilder.create(channelUID).withLabel(label).withDescription(description)
270 .withAcceptedItemType(itemType).withType(channelTypeUID).build();
272 logger.debug("UPnP device {}, created channel {}", thing.getLabel(), channelId);
274 updatedChannels.add(channel);
275 updatedChannelUIDs.add(channelUID);
276 updateChannels = true;
279 protected void updateChannels() {
280 if (updateChannels) {
281 List<Channel> channels = thing.getChannels().stream().filter(c -> !updatedChannelUIDs.contains(c.getUID()))
282 .collect(Collectors.toList());
283 channels.addAll(updatedChannels);
284 final ThingBuilder thingBuilder = editThing();
285 thingBuilder.withChannels(channels);
286 updateThing(thingBuilder.build());
288 updatedChannels.clear();
289 updatedChannelUIDs.clear();
290 updateChannels = false;
294 * Invoke PrepareForConnection on the UPnP Connection Manager.
295 * Result is received in {@link onValueReceived}.
297 * @param remoteProtocolInfo
298 * @param peerConnectionManager
299 * @param peerConnectionId
302 protected void prepareForConnection(String remoteProtocolInfo, String peerConnectionManager, int peerConnectionId,
304 CompletableFuture<Boolean> settingConnection = isConnectionIdSet;
305 CompletableFuture<Boolean> settingAVTransport = isAvTransportIdSet;
306 CompletableFuture<Boolean> settingRcs = isRcsIdSet;
307 if (settingConnection != null) {
308 settingConnection.complete(false);
310 if (settingAVTransport != null) {
311 settingAVTransport.complete(false);
313 if (settingRcs != null) {
314 settingRcs.complete(false);
317 // Set new futures, so we don't try to use service when connection id's are not known yet
318 isConnectionIdSet = new CompletableFuture<Boolean>();
319 isAvTransportIdSet = new CompletableFuture<Boolean>();
320 isRcsIdSet = new CompletableFuture<Boolean>();
322 HashMap<String, String> inputs = new HashMap<String, String>();
323 inputs.put("RemoteProtocolInfo", remoteProtocolInfo);
324 inputs.put("PeerConnectionManager", peerConnectionManager);
325 inputs.put("PeerConnectionID", Integer.toString(peerConnectionId));
326 inputs.put("Direction", direction);
328 invokeAction(CONNECTION_MANAGER, "PrepareForConnection", inputs);
332 * Invoke ConnectionComplete on UPnP Connection Manager.
334 protected void connectionComplete() {
335 Map<String, String> inputs = Collections.singletonMap(CONNECTION_ID, Integer.toString(connectionId));
337 invokeAction(CONNECTION_MANAGER, "ConnectionComplete", inputs);
341 * Invoke GetCurrentConnectionIDs on the UPnP Connection Manager.
342 * Result is received in {@link onValueReceived}.
344 protected void getCurrentConnectionIDs() {
345 Map<String, String> inputs = Collections.emptyMap();
347 invokeAction(CONNECTION_MANAGER, "GetCurrentConnectionIDs", inputs);
351 * Invoke GetCurrentConnectionInfo on the UPnP Connection Manager.
352 * Result is received in {@link onValueReceived}.
354 protected void getCurrentConnectionInfo() {
355 CompletableFuture<Boolean> settingAVTransport = isAvTransportIdSet;
356 CompletableFuture<Boolean> settingRcs = isRcsIdSet;
357 if (settingAVTransport != null) {
358 settingAVTransport.complete(false);
360 if (settingRcs != null) {
361 settingRcs.complete(false);
364 // Set new futures, so we don't try to use service when connection id's are not known yet
365 isAvTransportIdSet = new CompletableFuture<Boolean>();
366 isRcsIdSet = new CompletableFuture<Boolean>();
368 // ConnectionID will default to 0 if not set through prepareForConnection method
369 Map<String, String> inputs = Collections.singletonMap(CONNECTION_ID, Integer.toString(connectionId));
371 invokeAction(CONNECTION_MANAGER, "GetCurrentConnectionInfo", inputs);
375 * Invoke GetFeatureList on the UPnP Connection Manager.
376 * Result is received in {@link onValueReceived}.
378 protected void getFeatureList() {
379 Map<String, String> inputs = Collections.emptyMap();
381 invokeAction(CONNECTION_MANAGER, "GetFeatureList", inputs);
385 * Invoke GetProtocolInfo on UPnP Connection Manager.
386 * Result is received in {@link onValueReceived}.
388 protected void getProtocolInfo() {
389 Map<String, String> inputs = Collections.emptyMap();
391 invokeAction(CONNECTION_MANAGER, "GetProtocolInfo", inputs);
395 public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
396 logger.debug("UPnP device {} received subscription reply {} from service {}", thing.getLabel(), succeeded,
399 upnpSubscribed = false;
400 String msg = String.format("@text/offline.subscription-failed [ \"%1$s\", \"%2$s\" ]", service,
402 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
407 public void onStatusChanged(boolean status) {
408 logger.debug("UPnP device {} received status update {}", thing.getLabel(), status);
412 String msg = String.format("@text/offline.communication-lost [ \"%s\" ]", thing.getLabel());
413 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
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.
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);
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);
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);
449 result = preProcessInvokeActionResult(inputs, serviceId, actionId, result);
451 for (String variable : result.keySet()) {
452 onValueReceived(variable, result.get(variable), serviceId);
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.
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));
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.
489 protected @Nullable String preProcessValueReceived(Map<String, String> inputs, @Nullable String variable,
490 @Nullable String value, @Nullable String service, @Nullable String action) {
495 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
496 if (variable == null || value == null) {
501 onValueReceivedConnectionId(value);
503 case AV_TRANSPORT_ID:
504 onValueReceivedAVTransportId(value);
507 onValueReceivedRcsId(value);
511 if (!value.isEmpty()) {
512 updateProtocolInfo(value);
520 private void onValueReceivedConnectionId(@Nullable String value) {
522 connectionId = (value == null) ? 0 : Integer.parseInt(value);
523 } catch (NumberFormatException e) {
526 CompletableFuture<Boolean> connectionIdFuture = isConnectionIdSet;
527 if (connectionIdFuture != null) {
528 connectionIdFuture.complete(true);
532 private void onValueReceivedAVTransportId(@Nullable String value) {
534 avTransportId = (value == null) ? 0 : Integer.parseInt(value);
535 } catch (NumberFormatException e) {
538 CompletableFuture<Boolean> avTransportIdFuture = isAvTransportIdSet;
539 if (avTransportIdFuture != null) {
540 avTransportIdFuture.complete(true);
544 private void onValueReceivedRcsId(@Nullable String value) {
546 rcsId = (value == null) ? 0 : Integer.parseInt(value);
547 } catch (NumberFormatException e) {
550 CompletableFuture<Boolean> rcsIdFuture = isRcsIdSet;
551 if (rcsIdFuture != null) {
552 rcsIdFuture.complete(true);
557 public @Nullable String getUDN() {
561 protected boolean checkForConnectionIds() {
562 return checkForConnectionId(isConnectionIdSet) & checkForConnectionId(isAvTransportIdSet)
563 & checkForConnectionId(isRcsIdSet);
566 private boolean checkForConnectionId(@Nullable CompletableFuture<Boolean> future) {
568 if (future != null) {
569 return future.get(config.responseTimeout, TimeUnit.MILLISECONDS);
571 } catch (InterruptedException | ExecutionException | TimeoutException e) {
578 * Update internal representation of supported protocols, needs to be implemented in derived classes.
582 protected abstract void updateProtocolInfo(String value);
585 * Subscribe this handler as a participant to a GENA subscription.
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);
598 * Remove this handler from the GENA subscriptions.
602 protected void removeSubscription(String serviceId) {
603 if (upnpIOService.isRegistered(this)) {
604 upnpIOService.removeSubscription(this, serviceId);
608 protected void addSubscriptions() {
609 upnpSubscribed = true;
611 for (String subscription : serviceSubscriptions) {
612 addSubscription(subscription, SUBSCRIPTION_DURATION_SECONDS);
614 subscriptionRefreshJob = upnpScheduler.scheduleWithFixedDelay(subscriptionRefresh,
615 SUBSCRIPTION_DURATION_SECONDS / 2, SUBSCRIPTION_DURATION_SECONDS / 2, TimeUnit.SECONDS);
617 // This action should exist on all media devices and return a result, so a good candidate for testing the
619 upnpIOService.addStatusListener(this, CONNECTION_MANAGER, "GetCurrentConnectionIDs", config.refresh);
622 protected void removeSubscriptions() {
623 cancelSubscriptionRefreshJob();
625 for (String subscription : serviceSubscriptions) {
626 removeSubscription(subscription);
629 upnpIOService.removeStatusListener(this);
631 upnpSubscribed = false;
634 private void cancelSubscriptionRefreshJob() {
635 ScheduledFuture<?> refreshJob = subscriptionRefreshJob;
637 if (refreshJob != null) {
638 refreshJob.cancel(true);
640 subscriptionRefreshJob = null;
644 public abstract void playlistsListChanged();
647 * Get access to all device info through the UPnP {@link RemoteDevice}.
649 * @return UPnP RemoteDevice
651 protected @Nullable RemoteDevice getDevice() {