2 * Copyright (c) 2010-2022 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.pulseaudio.internal.handler;
15 import static org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants.*;
17 import java.io.IOException;
18 import java.math.BigDecimal;
19 import java.util.ArrayList;
20 import java.util.Collections;
21 import java.util.Hashtable;
22 import java.util.List;
25 import java.util.concurrent.ConcurrentHashMap;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
28 import java.util.stream.Collectors;
29 import java.util.stream.Stream;
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.openhab.binding.pulseaudio.internal.PulseAudioAudioSink;
34 import org.openhab.binding.pulseaudio.internal.PulseAudioAudioSource;
35 import org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants;
36 import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig;
37 import org.openhab.binding.pulseaudio.internal.items.Sink;
38 import org.openhab.binding.pulseaudio.internal.items.SinkInput;
39 import org.openhab.binding.pulseaudio.internal.items.Source;
40 import org.openhab.core.audio.AudioFormat;
41 import org.openhab.core.audio.AudioSink;
42 import org.openhab.core.audio.AudioSource;
43 import org.openhab.core.config.core.Configuration;
44 import org.openhab.core.library.types.DecimalType;
45 import org.openhab.core.library.types.IncreaseDecreaseType;
46 import org.openhab.core.library.types.OnOffType;
47 import org.openhab.core.library.types.PercentType;
48 import org.openhab.core.library.types.StringType;
49 import org.openhab.core.thing.Bridge;
50 import org.openhab.core.thing.ChannelUID;
51 import org.openhab.core.thing.Thing;
52 import org.openhab.core.thing.ThingStatus;
53 import org.openhab.core.thing.ThingStatusDetail;
54 import org.openhab.core.thing.ThingStatusInfo;
55 import org.openhab.core.thing.ThingTypeUID;
56 import org.openhab.core.thing.ThingUID;
57 import org.openhab.core.thing.binding.BaseThingHandler;
58 import org.openhab.core.thing.binding.ThingHandler;
59 import org.openhab.core.types.Command;
60 import org.openhab.core.types.RefreshType;
61 import org.openhab.core.types.State;
62 import org.openhab.core.types.UnDefType;
63 import org.osgi.framework.BundleContext;
64 import org.osgi.framework.ServiceRegistration;
65 import org.slf4j.Logger;
66 import org.slf4j.LoggerFactory;
69 * The {@link PulseaudioHandler} is responsible for handling commands, which are
70 * sent to one of the channels.
72 * @author Tobias Bräutigam - Initial contribution
73 * @author Miguel Álvarez - Register audio source and refactor
76 public class PulseaudioHandler extends BaseThingHandler implements DeviceStatusListener {
78 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
79 .unmodifiableSet(Stream.of(SINK_THING_TYPE, COMBINED_SINK_THING_TYPE, SINK_INPUT_THING_TYPE,
80 SOURCE_THING_TYPE, SOURCE_OUTPUT_THING_TYPE).collect(Collectors.toSet()));
81 private final Logger logger = LoggerFactory.getLogger(PulseaudioHandler.class);
82 private final int refresh = 60; // refresh every minute as default
84 private @Nullable PulseaudioBridgeHandler bridgeHandler;
85 private @Nullable String name;
86 private @Nullable ScheduledFuture<?> refreshJob;
87 private @Nullable PulseAudioAudioSink audioSink;
88 private @Nullable PulseAudioAudioSource audioSource;
89 private @Nullable Integer savedVolume;
91 private final Map<String, ServiceRegistration<AudioSink>> audioSinkRegistrations = new ConcurrentHashMap<>();
92 private final Map<String, ServiceRegistration<AudioSource>> audioSourceRegistrations = new ConcurrentHashMap<>();
94 private final BundleContext bundleContext;
96 public PulseaudioHandler(Thing thing, BundleContext bundleContext) {
98 this.bundleContext = bundleContext;
102 public void initialize() {
103 Configuration config = getThing().getConfiguration();
104 name = (String) config.get(DEVICE_PARAMETER_NAME);
106 updateStatus(ThingStatus.UNKNOWN);
107 deviceOnlineWatchdog();
109 // if it's a SINK thing, then maybe we have to activate the audio sink
110 if (SINK_THING_TYPE.equals(thing.getThingTypeUID())) {
111 // check the property to see if we it's enabled :
112 Boolean sinkActivated = (Boolean) thing.getConfiguration().get(DEVICE_PARAMETER_AUDIO_SINK_ACTIVATION);
113 if (sinkActivated != null && sinkActivated) {
117 // if it's a SOURCE thing, then maybe we have to activate the audio source
118 if (SOURCE_THING_TYPE.equals(thing.getThingTypeUID())) {
119 // check the property to see if we it's enabled :
120 Boolean sourceActivated = (Boolean) thing.getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_ACTIVATION);
121 if (sourceActivated != null && sourceActivated) {
127 private void audioSinkSetup() {
128 final PulseaudioHandler thisHandler = this;
129 scheduler.submit(new Runnable() {
132 // Register the sink as an audio sink in openhab
133 logger.trace("Registering an audio sink for pulse audio sink thing {}", thing.getUID());
134 PulseAudioAudioSink audioSink = new PulseAudioAudioSink(thisHandler, scheduler);
135 setAudioSink(audioSink);
137 audioSink.connectIfNeeded();
138 } catch (IOException e) {
139 logger.warn("pulseaudio binding cannot connect to the module-simple-protocol-tcp on {} ({})",
140 getHost(), e.getMessage());
141 } catch (InterruptedException i) {
142 logger.info("Interrupted during sink audio connection: {}", i.getMessage());
145 audioSink.scheduleDisconnect();
147 @SuppressWarnings("unchecked")
148 ServiceRegistration<AudioSink> reg = (ServiceRegistration<AudioSink>) bundleContext
149 .registerService(AudioSink.class.getName(), audioSink, new Hashtable<>());
150 audioSinkRegistrations.put(thing.getUID().toString(), reg);
155 private void audioSourceSetup() {
156 final PulseaudioHandler thisHandler = this;
157 scheduler.submit(new Runnable() {
160 // Register the source as an audio source in openhab
161 logger.trace("Registering an audio source for pulse audio source thing {}", thing.getUID());
162 PulseAudioAudioSource audioSource = new PulseAudioAudioSource(thisHandler, scheduler);
163 setAudioSource(audioSource);
165 audioSource.connectIfNeeded();
166 } catch (IOException e) {
167 logger.warn("pulseaudio binding cannot connect to the module-simple-protocol-tcp on {} ({})",
168 getHost(), e.getMessage());
169 } catch (InterruptedException i) {
170 logger.info("Interrupted during source audio connection: {}", i.getMessage());
173 audioSource.scheduleDisconnect();
175 @SuppressWarnings("unchecked")
176 ServiceRegistration<AudioSource> reg = (ServiceRegistration<AudioSource>) bundleContext
177 .registerService(AudioSource.class.getName(), audioSource, new Hashtable<>());
178 audioSourceRegistrations.put(thing.getUID().toString(), reg);
184 public void dispose() {
185 ScheduledFuture<?> job = refreshJob;
186 if (job != null && !job.isCancelled()) {
190 PulseaudioBridgeHandler briHandler = bridgeHandler;
191 if (briHandler != null) {
192 briHandler.unregisterDeviceStatusListener(this);
193 bridgeHandler = null;
195 logger.trace("Thing {} {} disposed.", getThing().getUID(), name);
197 PulseAudioAudioSink sink = audioSink;
201 PulseAudioAudioSource source = audioSource;
202 if (source != null) {
205 // Unregister the potential pulse audio sink's audio sink
206 ServiceRegistration<AudioSink> sinkReg = audioSinkRegistrations.remove(getThing().getUID().toString());
207 if (sinkReg != null) {
208 logger.trace("Unregistering the audio sync service for pulse audio sink thing {}", getThing().getUID());
209 sinkReg.unregister();
211 // Unregister the potential pulse audio source's audio sources
212 ServiceRegistration<AudioSource> sourceReg = audioSourceRegistrations.remove(getThing().getUID().toString());
213 if (sourceReg != null) {
214 logger.trace("Unregistering the audio sync service for pulse audio source thing {}", getThing().getUID());
215 sourceReg.unregister();
220 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
221 if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE
222 && getThing().getStatusInfo().getStatusDetail() == ThingStatusDetail.BRIDGE_OFFLINE) {
223 // Bridge is now ONLINE, restart the refresh job to get an update of the thing status without waiting
224 // its next planned run
225 ScheduledFuture<?> job = refreshJob;
226 if (job != null && !job.isCancelled()) {
230 deviceOnlineWatchdog();
231 } else if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE
232 || bridgeStatusInfo.getStatus() == ThingStatus.UNKNOWN) {
233 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
237 private void deviceOnlineWatchdog() {
238 Runnable runnable = () -> {
240 PulseaudioBridgeHandler bridgeHandler = getPulseaudioBridgeHandler();
241 if (bridgeHandler != null) {
242 if (bridgeHandler.getThing().getStatus() == ThingStatus.ONLINE) {
243 if (bridgeHandler.getDevice(name) == null) {
244 updateStatus(ThingStatus.OFFLINE);
245 this.bridgeHandler = null;
247 updateStatus(ThingStatus.ONLINE);
250 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
253 logger.debug("Bridge for pulseaudio device {} not found.", name);
254 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
256 } catch (Exception e) {
257 logger.debug("Exception occurred during execution: {}", e.getMessage(), e);
258 this.bridgeHandler = null;
262 refreshJob = scheduler.scheduleWithFixedDelay(runnable, 0, refresh, TimeUnit.SECONDS);
265 private synchronized @Nullable PulseaudioBridgeHandler getPulseaudioBridgeHandler() {
266 if (this.bridgeHandler == null) {
267 Bridge bridge = getBridge();
268 if (bridge == null) {
269 logger.debug("Required bridge not defined for device {}.", name);
272 ThingHandler handler = bridge.getHandler();
273 if (handler instanceof PulseaudioBridgeHandler) {
274 this.bridgeHandler = (PulseaudioBridgeHandler) handler;
275 this.bridgeHandler.registerDeviceStatusListener(this);
277 logger.debug("No available bridge handler found for device {} bridge {} .", name, bridge.getUID());
281 return this.bridgeHandler;
285 public void handleCommand(ChannelUID channelUID, Command command) {
286 PulseaudioBridgeHandler briHandler = getPulseaudioBridgeHandler();
287 if (briHandler == null) {
288 logger.debug("pulseaudio server bridge handler not found. Cannot handle command without bridge.");
291 if (command instanceof RefreshType) {
292 briHandler.handleCommand(channelUID, command);
296 AbstractAudioDeviceConfig device = briHandler.getDevice(name);
297 if (device == null) {
298 logger.warn("device {} not found", name);
299 updateStatus(ThingStatus.OFFLINE);
300 bridgeHandler = null;
303 State updateState = UnDefType.UNDEF;
304 if (channelUID.getId().equals(VOLUME_CHANNEL)) {
305 if (command instanceof IncreaseDecreaseType) {
306 // refresh to get the current volume level
307 briHandler.getClient().update();
308 device = briHandler.getDevice(name);
309 if (device == null) {
310 logger.warn("missing device info, aborting");
313 int oldVolume = device.getVolume();
314 int newVolume = oldVolume;
315 if (command.equals(IncreaseDecreaseType.INCREASE)) {
316 newVolume = Math.min(100, oldVolume + 5);
318 if (command.equals(IncreaseDecreaseType.DECREASE)) {
319 newVolume = Math.max(0, oldVolume - 5);
321 briHandler.getClient().setVolumePercent(device, newVolume);
322 updateState = new PercentType(newVolume);
323 savedVolume = newVolume;
324 } else if (command instanceof PercentType) {
325 DecimalType volume = (DecimalType) command;
326 briHandler.getClient().setVolumePercent(device, volume.intValue());
327 updateState = (PercentType) command;
328 savedVolume = volume.intValue();
329 } else if (command instanceof DecimalType) {
331 DecimalType volume = (DecimalType) command;
332 briHandler.getClient().setVolume(device, volume.intValue());
333 updateState = (DecimalType) command;
334 savedVolume = volume.intValue();
336 } else if (channelUID.getId().equals(MUTE_CHANNEL)) {
337 if (command instanceof OnOffType) {
338 briHandler.getClient().setMute(device, OnOffType.ON.equals(command));
339 updateState = (OnOffType) command;
341 } else if (channelUID.getId().equals(SLAVES_CHANNEL)) {
342 if (device instanceof Sink && ((Sink) device).isCombinedSink()) {
343 if (command instanceof StringType) {
344 List<Sink> slaves = new ArrayList<>();
345 for (String slaveName : command.toString().split(",")) {
346 Sink slave = briHandler.getClient().getSink(slaveName.trim());
351 if (!slaves.isEmpty()) {
352 briHandler.getClient().setCombinedSinkSlaves(((Sink) device), slaves);
356 logger.warn("{} is no combined sink", device);
358 } else if (channelUID.getId().equals(ROUTE_TO_SINK_CHANNEL)) {
359 if (device instanceof SinkInput) {
361 if (command instanceof DecimalType) {
362 newSink = briHandler.getClient().getSink(((DecimalType) command).intValue());
364 newSink = briHandler.getClient().getSink(command.toString());
366 if (newSink != null) {
367 logger.debug("rerouting {} to {}", device, newSink);
368 briHandler.getClient().moveSinkInput(((SinkInput) device), newSink);
369 updateState = new StringType(newSink.getPaName());
371 logger.warn("no sink {} found", command.toString());
375 logger.trace("updating {} to {}", channelUID, updateState);
376 if (!updateState.equals(UnDefType.UNDEF)) {
377 updateState(channelUID, updateState);
383 * Use last checked volume for faster access
387 public int getLastVolume() {
388 if (savedVolume == null) {
389 PulseaudioBridgeHandler briHandler = getPulseaudioBridgeHandler();
390 if (briHandler != null) {
391 // refresh to get the current volume level
392 briHandler.getClient().update();
393 AbstractAudioDeviceConfig device = briHandler.getDevice(name);
394 if (device != null) {
395 savedVolume = device.getVolume();
399 return savedVolume == null ? 50 : savedVolume;
402 public void setVolume(int volume) {
403 PulseaudioBridgeHandler briHandler = getPulseaudioBridgeHandler();
404 if (briHandler == null) {
405 logger.warn("bridge is not ready");
408 AbstractAudioDeviceConfig device = briHandler.getDevice(name);
409 if (device == null) {
410 logger.warn("missing device info, aborting");
413 briHandler.getClient().setVolumePercent(device, volume);
414 updateState(VOLUME_CHANNEL, new PercentType(volume));
415 savedVolume = volume;
419 public void onDeviceStateChanged(ThingUID bridge, AbstractAudioDeviceConfig device) {
420 if (device.getPaName().equals(name)) {
421 updateStatus(ThingStatus.ONLINE);
422 logger.debug("Updating states of {} id: {}", device, VOLUME_CHANNEL);
423 savedVolume = device.getVolume();
424 updateState(VOLUME_CHANNEL, new PercentType(savedVolume));
425 updateState(MUTE_CHANNEL, device.isMuted() ? OnOffType.ON : OnOffType.OFF);
426 updateState(STATE_CHANNEL,
427 device.getState() != null ? new StringType(device.getState().toString()) : new StringType("-"));
428 if (device instanceof SinkInput) {
429 updateState(ROUTE_TO_SINK_CHANNEL,
430 ((SinkInput) device).getSink() != null
431 ? new StringType(((SinkInput) device).getSink().getPaName())
432 : new StringType("-"));
434 if (device instanceof Sink && ((Sink) device).isCombinedSink()) {
435 updateState(SLAVES_CHANNEL, new StringType(String.join(",", ((Sink) device).getCombinedSinkNames())));
440 public String getHost() {
441 Bridge bridge = getBridge();
442 if (bridge != null) {
443 return (String) bridge.getConfiguration().get(PulseaudioBindingConstants.BRIDGE_PARAMETER_HOST);
445 logger.warn("A bridge must be configured for this pulseaudio thing");
451 * This method will scan the pulseaudio server to find the port on which the module/sink is listening
452 * If no module is listening, then it will command the module to load on the pulse audio server,
454 * @return the port on which the pulseaudio server is listening for this sink
455 * @throws IOException when device info is not available
456 * @throws InterruptedException when interrupted during the loading module wait
458 public int getSimpleTcpPort() throws IOException, InterruptedException {
459 var briHandler = getPulseaudioBridgeHandler();
460 if (briHandler == null) {
461 throw new IOException("bridge is not ready");
463 AbstractAudioDeviceConfig device = briHandler.getDevice(name);
464 if (device == null) {
465 throw new IOException("missing device info, device appears to be offline");
467 String simpleTcpPortPrefName = (device instanceof Source) ? DEVICE_PARAMETER_AUDIO_SOURCE_PORT
468 : DEVICE_PARAMETER_AUDIO_SINK_PORT;
469 BigDecimal simpleTcpPortPref = ((BigDecimal) getThing().getConfiguration().get(simpleTcpPortPrefName));
470 int simpleTcpPort = simpleTcpPortPref != null ? simpleTcpPortPref.intValue()
471 : MODULE_SIMPLE_PROTOCOL_TCP_DEFAULT_PORT;
472 String simpleFormat = ((String) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_FORMAT));
473 BigDecimal simpleRate = (BigDecimal) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_RATE);
474 BigDecimal simpleChannels = (BigDecimal) getThing().getConfiguration()
475 .get(DEVICE_PARAMETER_AUDIO_SOURCE_CHANNELS);
476 return briHandler.getClient()
477 .loadModuleSimpleProtocolTcpIfNeeded(device, simpleTcpPort, simpleFormat, simpleRate, simpleChannels)
478 .orElse(simpleTcpPort);
481 public @Nullable AudioFormat getSourceAudioFormat() {
482 String simpleFormat = ((String) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_FORMAT));
483 BigDecimal simpleRate = ((BigDecimal) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_RATE));
484 BigDecimal simpleChannels = ((BigDecimal) getThing().getConfiguration()
485 .get(DEVICE_PARAMETER_AUDIO_SOURCE_CHANNELS));
486 if (simpleFormat == null || simpleRate == null || simpleChannels == null) {
489 switch (simpleFormat) {
491 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, null, 8, 1,
492 simpleRate.longValue(), simpleChannels.intValue());
494 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false, 16, 1,
495 simpleRate.longValue(), simpleChannels.intValue());
497 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, true, 16, 1,
498 simpleRate.longValue(), simpleChannels.intValue());
500 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, false, 24, 1,
501 simpleRate.longValue(), simpleChannels.intValue());
503 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, true, 24, 1,
504 simpleRate.longValue(), simpleChannels.intValue());
506 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, false, 32, 1,
507 simpleRate.longValue(), simpleChannels.intValue());
509 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, true, 32, 1,
510 simpleRate.longValue(), simpleChannels.intValue());
512 logger.warn("unsupported format {}", simpleFormat);
517 public int getIdleTimeout() {
518 var handler = getPulseaudioBridgeHandler();
519 if (handler == null) {
522 AbstractAudioDeviceConfig device = handler.getDevice(name);
523 String idleTimeoutPropName = (device instanceof Source) ? DEVICE_PARAMETER_AUDIO_SOURCE_IDLE_TIMEOUT
524 : DEVICE_PARAMETER_AUDIO_SINK_IDLE_TIMEOUT;
525 var idleTimeout = (BigDecimal) getThing().getConfiguration().get(idleTimeoutPropName);
526 return idleTimeout != null ? idleTimeout.intValue() : 30000;
529 public int getBasicProtocolSOTimeout() {
530 var soTimeout = (BigDecimal) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOCKET_SO_TIMEOUT);
531 return soTimeout != null ? soTimeout.intValue() : 500;
535 public void onDeviceRemoved(PulseaudioBridgeHandler bridge, AbstractAudioDeviceConfig device) {
536 if (device.getPaName().equals(name)) {
537 bridgeHandler.unregisterDeviceStatusListener(this);
538 bridgeHandler = null;
539 audioSink.disconnect();
541 updateStatus(ThingStatus.OFFLINE);
546 public void onDeviceAdded(Bridge bridge, AbstractAudioDeviceConfig device) {
547 logger.trace("new device discovered {} by {}", device, bridge);
550 public void setAudioSink(PulseAudioAudioSink audioSink) {
551 this.audioSink = audioSink;
554 public void setAudioSource(PulseAudioAudioSource audioSource) {
555 this.audioSource = audioSource;