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;
24 import java.util.Optional;
26 import java.util.concurrent.ConcurrentHashMap;
27 import java.util.stream.Collectors;
28 import java.util.stream.Stream;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.pulseaudio.internal.PulseAudioAudioSink;
33 import org.openhab.binding.pulseaudio.internal.PulseAudioAudioSource;
34 import org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants;
35 import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig;
36 import org.openhab.binding.pulseaudio.internal.items.Sink;
37 import org.openhab.binding.pulseaudio.internal.items.SinkInput;
38 import org.openhab.binding.pulseaudio.internal.items.Source;
39 import org.openhab.core.audio.AudioFormat;
40 import org.openhab.core.audio.AudioSink;
41 import org.openhab.core.audio.AudioSource;
42 import org.openhab.core.config.core.Configuration;
43 import org.openhab.core.library.types.DecimalType;
44 import org.openhab.core.library.types.IncreaseDecreaseType;
45 import org.openhab.core.library.types.OnOffType;
46 import org.openhab.core.library.types.PercentType;
47 import org.openhab.core.library.types.StringType;
48 import org.openhab.core.thing.Bridge;
49 import org.openhab.core.thing.ChannelUID;
50 import org.openhab.core.thing.Thing;
51 import org.openhab.core.thing.ThingStatus;
52 import org.openhab.core.thing.ThingStatusDetail;
53 import org.openhab.core.thing.ThingStatusInfo;
54 import org.openhab.core.thing.ThingTypeUID;
55 import org.openhab.core.thing.binding.BaseThingHandler;
56 import org.openhab.core.thing.binding.ThingHandler;
57 import org.openhab.core.types.Command;
58 import org.openhab.core.types.RefreshType;
59 import org.openhab.core.types.State;
60 import org.openhab.core.types.UnDefType;
61 import org.osgi.framework.BundleContext;
62 import org.osgi.framework.ServiceRegistration;
63 import org.slf4j.Logger;
64 import org.slf4j.LoggerFactory;
67 * The {@link PulseaudioHandler} is responsible for handling commands, which are
68 * sent to one of the channels.
70 * @author Tobias Bräutigam - Initial contribution
71 * @author Miguel Álvarez - Register audio source and refactor
74 public class PulseaudioHandler extends BaseThingHandler {
76 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
77 .unmodifiableSet(Stream.of(SINK_THING_TYPE, COMBINED_SINK_THING_TYPE, SINK_INPUT_THING_TYPE,
78 SOURCE_THING_TYPE, SOURCE_OUTPUT_THING_TYPE).collect(Collectors.toSet()));
79 private final Logger logger = LoggerFactory.getLogger(PulseaudioHandler.class);
81 private String name = "";
82 private @Nullable PulseAudioAudioSink audioSink;
83 private @Nullable PulseAudioAudioSource audioSource;
84 private @Nullable Integer savedVolume;
86 private final Map<String, ServiceRegistration<AudioSink>> audioSinkRegistrations = new ConcurrentHashMap<>();
87 private final Map<String, ServiceRegistration<AudioSource>> audioSourceRegistrations = new ConcurrentHashMap<>();
89 private final BundleContext bundleContext;
91 public PulseaudioHandler(Thing thing, BundleContext bundleContext) {
93 this.bundleContext = bundleContext;
97 public void initialize() {
98 Configuration config = getThing().getConfiguration();
99 name = (String) config.get(DEVICE_PARAMETER_NAME);
100 initializeWithTheBridge();
103 public String getName() {
107 private void audioSinkSetup() {
108 if (audioSink != null) {
109 // Audio sink is already setup
112 if (!SINK_THING_TYPE.equals(thing.getThingTypeUID())) {
115 // check the property to see if it's enabled :
116 Boolean sinkActivated = (Boolean) thing.getConfiguration().get(DEVICE_PARAMETER_AUDIO_SINK_ACTIVATION);
117 if (sinkActivated == null || !sinkActivated.booleanValue()) {
120 final PulseaudioHandler thisHandler = this;
121 PulseAudioAudioSink audioSink = new PulseAudioAudioSink(thisHandler, scheduler);
122 scheduler.submit(new Runnable() {
125 PulseaudioHandler.this.audioSink = audioSink;
127 audioSink.connectIfNeeded();
128 } catch (IOException e) {
129 logger.warn("pulseaudio binding cannot connect to the module-simple-protocol-tcp on {} ({})",
130 getHost(), e.getMessage());
131 } catch (InterruptedException i) {
132 logger.info("Interrupted during sink audio connection: {}", i.getMessage());
135 audioSink.scheduleDisconnect();
139 // Register the sink as an audio sink in openhab
140 logger.trace("Registering an audio sink for pulse audio sink thing {}", thing.getUID());
141 @SuppressWarnings("unchecked")
142 ServiceRegistration<AudioSink> reg = (ServiceRegistration<AudioSink>) bundleContext
143 .registerService(AudioSink.class.getName(), audioSink, new Hashtable<>());
144 audioSinkRegistrations.put(thing.getUID().toString(), reg);
147 private void audioSinkUnsetup() {
148 PulseAudioAudioSink sink = audioSink;
153 // Unregister the potential pulse audio sink's audio sink
154 ServiceRegistration<AudioSink> sinkReg = audioSinkRegistrations.remove(getThing().getUID().toString());
155 if (sinkReg != null) {
156 logger.trace("Unregistering the audio sync service for pulse audio sink thing {}", getThing().getUID());
157 sinkReg.unregister();
161 private void audioSourceSetup() {
162 if (audioSource != null) {
163 // Audio source is already setup
166 if (!SOURCE_THING_TYPE.equals(thing.getThingTypeUID())) {
169 // check the property to see if it's enabled :
170 Boolean sourceActivated = (Boolean) thing.getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_ACTIVATION);
171 if (sourceActivated == null || !sourceActivated.booleanValue()) {
174 final PulseaudioHandler thisHandler = this;
175 PulseAudioAudioSource audioSource = new PulseAudioAudioSource(thisHandler, scheduler);
176 scheduler.submit(new Runnable() {
179 PulseaudioHandler.this.audioSource = audioSource;
181 audioSource.connectIfNeeded();
182 } catch (IOException e) {
183 logger.warn("pulseaudio binding cannot connect to the module-simple-protocol-tcp on {} ({})",
184 getHost(), e.getMessage());
185 } catch (InterruptedException i) {
186 logger.info("Interrupted during source audio connection: {}", i.getMessage());
189 audioSource.scheduleDisconnect();
193 // Register the source as an audio source in openhab
194 logger.trace("Registering an audio source for pulse audio source thing {}", thing.getUID());
195 @SuppressWarnings("unchecked")
196 ServiceRegistration<AudioSource> reg = (ServiceRegistration<AudioSource>) bundleContext
197 .registerService(AudioSource.class.getName(), audioSource, new Hashtable<>());
198 audioSourceRegistrations.put(thing.getUID().toString(), reg);
201 private void audioSourceUnsetup() {
202 PulseAudioAudioSource source = audioSource;
203 if (source != null) {
207 // Unregister the potential pulse audio source's audio sources
208 ServiceRegistration<AudioSource> sourceReg = audioSourceRegistrations.remove(getThing().getUID().toString());
209 if (sourceReg != null) {
210 logger.trace("Unregistering the audio sync service for pulse audio source thing {}", getThing().getUID());
211 sourceReg.unregister();
216 public void dispose() {
217 logger.trace("Thing {} {} disposed.", getThing().getUID(), name);
220 audioSourceUnsetup();
224 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
225 initializeWithTheBridge();
228 private void initializeWithTheBridge() {
229 PulseaudioBridgeHandler pulseaudioBridgeHandler = getPulseaudioBridgeHandler();
230 if (pulseaudioBridgeHandler == null) {
231 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
232 } else if (pulseaudioBridgeHandler.getThing().getStatus() != ThingStatus.ONLINE) {
233 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
235 deviceUpdate(pulseaudioBridgeHandler.getDevice(name));
239 private synchronized @Nullable PulseaudioBridgeHandler getPulseaudioBridgeHandler() {
240 Bridge bridge = getBridge();
241 if (bridge == null) {
242 logger.debug("Required bridge not defined for device {}.", name);
245 ThingHandler handler = bridge.getHandler();
246 if (handler instanceof PulseaudioBridgeHandler) {
247 return (PulseaudioBridgeHandler) handler;
249 logger.debug("No available bridge handler found for device {} bridge {} .", name, bridge.getUID());
255 public void handleCommand(ChannelUID channelUID, Command command) {
256 PulseaudioBridgeHandler briHandler = getPulseaudioBridgeHandler();
257 if (briHandler == null) {
258 logger.debug("pulseaudio server bridge handler not found. Cannot handle command without bridge.");
261 if (command instanceof RefreshType) {
262 briHandler.handleCommand(channelUID, command);
266 AbstractAudioDeviceConfig device = briHandler.getDevice(name);
267 if (device == null) {
268 logger.warn("device {} not found", name);
272 State updateState = UnDefType.UNDEF;
273 if (channelUID.getId().equals(VOLUME_CHANNEL)) {
274 if (command instanceof IncreaseDecreaseType) {
275 // refresh to get the current volume level
276 briHandler.getClient().update();
277 device = briHandler.getDevice(name);
278 if (device == null) {
279 logger.warn("missing device info, aborting");
282 int oldVolume = device.getVolume();
283 int newVolume = oldVolume;
284 if (command.equals(IncreaseDecreaseType.INCREASE)) {
285 newVolume = Math.min(100, oldVolume + 5);
287 if (command.equals(IncreaseDecreaseType.DECREASE)) {
288 newVolume = Math.max(0, oldVolume - 5);
290 briHandler.getClient().setVolumePercent(device, newVolume);
291 updateState = new PercentType(newVolume);
292 savedVolume = newVolume;
293 } else if (command instanceof PercentType) {
294 DecimalType volume = (DecimalType) command;
295 briHandler.getClient().setVolumePercent(device, volume.intValue());
296 updateState = (PercentType) command;
297 savedVolume = volume.intValue();
298 } else if (command instanceof DecimalType) {
300 DecimalType volume = (DecimalType) command;
301 briHandler.getClient().setVolume(device, volume.intValue());
302 updateState = (DecimalType) command;
303 savedVolume = volume.intValue();
305 } else if (channelUID.getId().equals(MUTE_CHANNEL)) {
306 if (command instanceof OnOffType) {
307 briHandler.getClient().setMute(device, OnOffType.ON.equals(command));
308 updateState = (OnOffType) command;
310 } else if (channelUID.getId().equals(SLAVES_CHANNEL)) {
311 if (device instanceof Sink && ((Sink) device).isCombinedSink()) {
312 if (command instanceof StringType) {
313 List<Sink> slaves = new ArrayList<>();
314 for (String slaveName : command.toString().split(",")) {
315 Sink slave = briHandler.getClient().getSink(slaveName.trim());
320 if (!slaves.isEmpty()) {
321 briHandler.getClient().setCombinedSinkSlaves(((Sink) device), slaves);
325 logger.warn("{} is no combined sink", device);
327 } else if (channelUID.getId().equals(ROUTE_TO_SINK_CHANNEL)) {
328 if (device instanceof SinkInput) {
330 if (command instanceof DecimalType) {
331 newSink = briHandler.getClient().getSink(((DecimalType) command).intValue());
333 newSink = briHandler.getClient().getSink(command.toString());
335 if (newSink != null) {
336 logger.debug("rerouting {} to {}", device, newSink);
337 briHandler.getClient().moveSinkInput(((SinkInput) device), newSink);
338 updateState = new StringType(newSink.getPaName());
340 logger.warn("no sink {} found", command.toString());
344 logger.trace("updating {} to {}", channelUID, updateState);
345 if (!updateState.equals(UnDefType.UNDEF)) {
346 updateState(channelUID, updateState);
352 * Use last checked volume for faster access
356 public Integer getLastVolume() {
357 Integer savedVolumeFinal = savedVolume;
358 if (savedVolumeFinal == null) {
359 PulseaudioBridgeHandler briHandler = getPulseaudioBridgeHandler();
360 if (briHandler != null) {
361 // refresh to get the current volume level
362 briHandler.getClient().update();
363 AbstractAudioDeviceConfig device = briHandler.getDevice(name);
364 if (device != null) {
365 savedVolume = savedVolumeFinal = device.getVolume();
369 return savedVolumeFinal == null ? 50 : savedVolumeFinal;
372 public void setVolume(int volume) {
373 PulseaudioBridgeHandler briHandler = getPulseaudioBridgeHandler();
374 if (briHandler == null) {
375 logger.warn("bridge is not ready");
378 AbstractAudioDeviceConfig device = briHandler.getDevice(name);
379 if (device == null) {
380 logger.warn("missing device info, aborting");
383 briHandler.getClient().setVolumePercent(device, volume);
384 updateState(VOLUME_CHANNEL, new PercentType(volume));
385 savedVolume = volume;
388 public void deviceUpdate(@Nullable AbstractAudioDeviceConfig device) {
389 if (device != null && device.getPaName().equals(name)) {
390 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
391 logger.debug("Updating states of {} id: {}", device, VOLUME_CHANNEL);
392 int actualVolume = device.getVolume();
393 savedVolume = actualVolume;
394 updateState(VOLUME_CHANNEL, new PercentType(actualVolume));
395 updateState(MUTE_CHANNEL, OnOffType.from(device.isMuted()));
396 org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig.State state = device.getState();
397 updateState(STATE_CHANNEL, state != null ? new StringType(state.toString()) : new StringType("-"));
398 if (device instanceof SinkInput) {
399 updateState(ROUTE_TO_SINK_CHANNEL, new StringType(
400 Optional.ofNullable(((SinkInput) device).getSink()).map(Sink::getPaName).orElse("-")));
402 if (device instanceof Sink && ((Sink) device).isCombinedSink()) {
403 updateState(SLAVES_CHANNEL, new StringType(String.join(",", ((Sink) device).getCombinedSinkNames())));
407 } else if (device == null) {
408 updateState(VOLUME_CHANNEL, UnDefType.UNDEF);
409 updateState(MUTE_CHANNEL, UnDefType.UNDEF);
410 updateState(STATE_CHANNEL, UnDefType.UNDEF);
411 if (SINK_INPUT_THING_TYPE.equals(thing.getThingTypeUID())) {
412 updateState(ROUTE_TO_SINK_CHANNEL, UnDefType.UNDEF);
414 if (COMBINED_SINK_THING_TYPE.equals(thing.getThingTypeUID())) {
415 updateState(SLAVES_CHANNEL, UnDefType.UNDEF);
418 audioSourceUnsetup();
419 updateStatus(ThingStatus.OFFLINE);
423 public String getHost() {
424 Bridge bridge = getBridge();
425 if (bridge != null) {
426 return (String) bridge.getConfiguration().get(PulseaudioBindingConstants.BRIDGE_PARAMETER_HOST);
428 logger.warn("A bridge must be configured for this pulseaudio thing");
434 * This method will scan the pulseaudio server to find the port on which the module/sink/source is listening
435 * If no module is listening, then it will command the module to load on the pulse audio server,
437 * @return the port on which the pulseaudio server is listening for this sink/source
438 * @throws IOException when device info is not available
439 * @throws InterruptedException when interrupted during the loading module wait
441 public int getSimpleTcpPortAndLoadModuleIfNecessary() throws IOException, InterruptedException {
442 var briHandler = getPulseaudioBridgeHandler();
443 if (briHandler == null) {
444 throw new IOException("bridge is not ready");
446 AbstractAudioDeviceConfig device = briHandler.getDevice(name);
447 if (device == null) {
448 throw new IOException("missing device info, device appears to be offline");
450 String simpleTcpPortPrefName = (device instanceof Source) ? DEVICE_PARAMETER_AUDIO_SOURCE_PORT
451 : DEVICE_PARAMETER_AUDIO_SINK_PORT;
452 BigDecimal simpleTcpPortPref = ((BigDecimal) getThing().getConfiguration().get(simpleTcpPortPrefName));
453 int simpleTcpPort = simpleTcpPortPref != null ? simpleTcpPortPref.intValue()
454 : MODULE_SIMPLE_PROTOCOL_TCP_DEFAULT_PORT;
455 String simpleFormat = ((String) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_FORMAT));
456 BigDecimal simpleRate = (BigDecimal) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_RATE);
457 BigDecimal simpleChannels = (BigDecimal) getThing().getConfiguration()
458 .get(DEVICE_PARAMETER_AUDIO_SOURCE_CHANNELS);
459 return briHandler.getClient()
460 .loadModuleSimpleProtocolTcpIfNeeded(device, simpleTcpPort, simpleFormat, simpleRate, simpleChannels)
461 .orElse(simpleTcpPort);
464 public @Nullable AudioFormat getSourceAudioFormat() {
465 String simpleFormat = ((String) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_FORMAT));
466 BigDecimal simpleRate = ((BigDecimal) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_RATE));
467 BigDecimal simpleChannels = ((BigDecimal) getThing().getConfiguration()
468 .get(DEVICE_PARAMETER_AUDIO_SOURCE_CHANNELS));
469 if (simpleFormat == null || simpleRate == null || simpleChannels == null) {
472 switch (simpleFormat) {
474 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, null, 8, 1,
475 simpleRate.longValue(), simpleChannels.intValue());
477 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false, 16, 1,
478 simpleRate.longValue(), simpleChannels.intValue());
480 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, true, 16, 1,
481 simpleRate.longValue(), simpleChannels.intValue());
483 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, false, 24, 1,
484 simpleRate.longValue(), simpleChannels.intValue());
486 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, true, 24, 1,
487 simpleRate.longValue(), simpleChannels.intValue());
489 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, false, 32, 1,
490 simpleRate.longValue(), simpleChannels.intValue());
492 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, true, 32, 1,
493 simpleRate.longValue(), simpleChannels.intValue());
495 logger.warn("unsupported format {}", simpleFormat);
500 public int getIdleTimeout() {
501 var idleTimeout = 3000;
502 var handler = getPulseaudioBridgeHandler();
503 if (handler != null) {
504 AbstractAudioDeviceConfig device = handler.getDevice(name);
505 String idleTimeoutPropName = (device instanceof Source) ? DEVICE_PARAMETER_AUDIO_SOURCE_IDLE_TIMEOUT
506 : DEVICE_PARAMETER_AUDIO_SINK_IDLE_TIMEOUT;
507 var idleTimeoutB = (BigDecimal) getThing().getConfiguration().get(idleTimeoutPropName);
508 if (idleTimeoutB != null) {
509 idleTimeout = idleTimeoutB.intValue();
515 public int getBasicProtocolSOTimeout() {
516 var soTimeout = (BigDecimal) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOCKET_SO_TIMEOUT);
517 return soTimeout != null ? soTimeout.intValue() : 500;