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 org.jupnp.registry.RegistryListener RegistryListener} or is updated. Configuration info can be retrieved
232 * 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 invokeAction}. It
420 * schedules and submits the call and calls {@link #onValueReceived} upon completion. All state updates or other
421 * actions depending on the results should be triggered from {@link #onValueReceived} because the class fields with
422 * results will be 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() {