2 * Copyright (c) 2010-2024 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.regex.PatternSyntaxException;
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.audio.utils.AudioSinkUtils;
44 import org.openhab.core.config.core.Configuration;
45 import org.openhab.core.library.types.DecimalType;
46 import org.openhab.core.library.types.IncreaseDecreaseType;
47 import org.openhab.core.library.types.OnOffType;
48 import org.openhab.core.library.types.PercentType;
49 import org.openhab.core.library.types.StringType;
50 import org.openhab.core.thing.Bridge;
51 import org.openhab.core.thing.ChannelUID;
52 import org.openhab.core.thing.Thing;
53 import org.openhab.core.thing.ThingStatus;
54 import org.openhab.core.thing.ThingStatusDetail;
55 import org.openhab.core.thing.ThingStatusInfo;
56 import org.openhab.core.thing.ThingTypeUID;
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 {
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);
83 private @Nullable DeviceIdentifier deviceIdentifier;
84 private @Nullable PulseAudioAudioSink audioSink;
85 private @Nullable PulseAudioAudioSource audioSource;
86 private @Nullable Integer savedVolume;
88 private final Map<String, ServiceRegistration<AudioSink>> audioSinkRegistrations = new ConcurrentHashMap<>();
89 private final Map<String, ServiceRegistration<AudioSource>> audioSourceRegistrations = new ConcurrentHashMap<>();
91 private final BundleContext bundleContext;
93 private AudioSinkUtils audioSinkUtils;
95 public PulseaudioHandler(Thing thing, BundleContext bundleContext, AudioSinkUtils audioSinkUtils) {
97 this.bundleContext = bundleContext;
98 this.audioSinkUtils = audioSinkUtils;
102 public void initialize() {
103 Configuration config = getThing().getConfiguration();
105 deviceIdentifier = new DeviceIdentifier((String) config.get(DEVICE_PARAMETER_NAME_OR_DESCRIPTION),
106 (String) config.get(DEVICE_PARAMETER_ADDITIONAL_FILTERS));
107 } catch (PatternSyntaxException p) {
108 deviceIdentifier = null;
109 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
110 "Incorrect regular expression: " + (String) config.get(DEVICE_PARAMETER_ADDITIONAL_FILTERS));
113 initializeWithTheBridge();
116 public @Nullable DeviceIdentifier getDeviceIdentifier() {
117 return deviceIdentifier;
120 private void audioSinkSetup() {
121 if (audioSink != null) {
122 // Audio sink is already setup
125 if (!SINK_THING_TYPE.equals(thing.getThingTypeUID())) {
128 // check the property to see if it's enabled :
129 Boolean sinkActivated = (Boolean) thing.getConfiguration().get(DEVICE_PARAMETER_AUDIO_SINK_ACTIVATION);
130 if (sinkActivated == null || !sinkActivated.booleanValue()) {
133 final PulseaudioHandler thisHandler = this;
134 PulseAudioAudioSink audioSink = new PulseAudioAudioSink(thisHandler, scheduler, audioSinkUtils);
135 scheduler.submit(new Runnable() {
138 PulseaudioHandler.this.audioSink = audioSink;
140 audioSink.connectIfNeeded();
141 } catch (IOException e) {
142 logger.warn("pulseaudio binding cannot connect to the module-simple-protocol-tcp on {} ({})",
143 getHost(), e.getMessage());
144 } catch (InterruptedException i) {
145 logger.info("Interrupted during sink audio connection: {}", i.getMessage());
150 // Register the sink as an audio sink in openhab
151 logger.trace("Registering an audio sink for pulse audio sink thing {}", thing.getUID());
152 @SuppressWarnings("unchecked")
153 ServiceRegistration<AudioSink> reg = (ServiceRegistration<AudioSink>) bundleContext
154 .registerService(AudioSink.class.getName(), audioSink, new Hashtable<>());
155 audioSinkRegistrations.put(thing.getUID().toString(), reg);
158 private void audioSinkUnsetup() {
159 PulseAudioAudioSink sink = audioSink;
164 // Unregister the potential pulse audio sink's audio sink
165 ServiceRegistration<AudioSink> sinkReg = audioSinkRegistrations.remove(getThing().getUID().toString());
166 if (sinkReg != null) {
167 logger.trace("Unregistering the audio sync service for pulse audio sink thing {}", getThing().getUID());
168 sinkReg.unregister();
172 private void audioSourceSetup() {
173 if (audioSource != null) {
174 // Audio source is already setup
177 if (!SOURCE_THING_TYPE.equals(thing.getThingTypeUID())) {
180 // check the property to see if it's enabled :
181 Boolean sourceActivated = (Boolean) thing.getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_ACTIVATION);
182 if (sourceActivated == null || !sourceActivated.booleanValue()) {
185 final PulseaudioHandler thisHandler = this;
186 PulseAudioAudioSource audioSource = new PulseAudioAudioSource(thisHandler, scheduler);
187 scheduler.submit(new Runnable() {
190 PulseaudioHandler.this.audioSource = audioSource;
192 audioSource.connectIfNeeded();
193 } catch (IOException e) {
194 logger.warn("pulseaudio binding cannot connect to the module-simple-protocol-tcp on {} ({})",
195 getHost(), e.getMessage());
196 } catch (InterruptedException i) {
197 logger.info("Interrupted during source audio connection: {}", i.getMessage());
202 // Register the source as an audio source in openhab
203 logger.trace("Registering an audio source for pulse audio source thing {}", thing.getUID());
204 @SuppressWarnings("unchecked")
205 ServiceRegistration<AudioSource> reg = (ServiceRegistration<AudioSource>) bundleContext
206 .registerService(AudioSource.class.getName(), audioSource, new Hashtable<>());
207 audioSourceRegistrations.put(thing.getUID().toString(), reg);
210 private void audioSourceUnsetup() {
211 PulseAudioAudioSource source = audioSource;
212 if (source != null) {
216 // Unregister the potential pulse audio source's audio sources
217 ServiceRegistration<AudioSource> sourceReg = audioSourceRegistrations.remove(getThing().getUID().toString());
218 if (sourceReg != null) {
219 logger.trace("Unregistering the audio sync service for pulse audio source thing {}", getThing().getUID());
220 sourceReg.unregister();
225 public void dispose() {
226 logger.trace("Thing {} {} disposed.", getThing().getUID(), safeGetDeviceNameOrDescription());
229 audioSourceUnsetup();
233 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
234 initializeWithTheBridge();
237 private void initializeWithTheBridge() {
238 PulseaudioBridgeHandler pulseaudioBridgeHandler = getPulseaudioBridgeHandler();
239 if (pulseaudioBridgeHandler == null) {
240 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
241 } else if (pulseaudioBridgeHandler.getThing().getStatus() != ThingStatus.ONLINE) {
242 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
244 deviceUpdate(pulseaudioBridgeHandler.getDevice(deviceIdentifier));
248 private synchronized @Nullable PulseaudioBridgeHandler getPulseaudioBridgeHandler() {
249 Bridge bridge = getBridge();
250 if (bridge == null) {
251 logger.debug("Required bridge not defined for device {}.", safeGetDeviceNameOrDescription());
254 ThingHandler handler = bridge.getHandler();
255 if (handler instanceof PulseaudioBridgeHandler pulseaudioBridgeHandler) {
256 return pulseaudioBridgeHandler;
258 logger.debug("No available bridge handler found for device {} bridge {} .",
259 safeGetDeviceNameOrDescription(), bridge.getUID());
265 public void handleCommand(ChannelUID channelUID, Command command) {
266 PulseaudioBridgeHandler briHandler = getPulseaudioBridgeHandler();
267 if (briHandler == null) {
268 logger.debug("pulseaudio server bridge handler not found. Cannot handle command without bridge.");
271 if (command instanceof RefreshType) {
272 briHandler.handleCommand(channelUID, command);
276 AbstractAudioDeviceConfig device = briHandler.getDevice(deviceIdentifier);
277 if (device == null) {
278 logger.warn("device {} not found", safeGetDeviceNameOrDescription());
282 State updateState = UnDefType.UNDEF;
283 if (channelUID.getId().equals(VOLUME_CHANNEL)) {
284 if (command instanceof IncreaseDecreaseType) {
285 // refresh to get the current volume level
286 briHandler.getClient().update();
287 device = briHandler.getDevice(deviceIdentifier);
288 if (device == null) {
289 logger.warn("missing device info, aborting");
292 int oldVolume = device.getVolume();
293 int newVolume = oldVolume;
294 if (command.equals(IncreaseDecreaseType.INCREASE)) {
295 newVolume = Math.min(100, oldVolume + 5);
297 if (command.equals(IncreaseDecreaseType.DECREASE)) {
298 newVolume = Math.max(0, oldVolume - 5);
300 briHandler.getClient().setVolumePercent(device, newVolume);
301 updateState = new PercentType(newVolume);
302 savedVolume = newVolume;
303 } else if (command instanceof PercentType volume) {
304 briHandler.getClient().setVolumePercent(device, volume.intValue());
305 updateState = volume;
306 savedVolume = volume.intValue();
307 } else if (command instanceof DecimalType volume) {
308 briHandler.getClient().setVolume(device, volume.intValue());
309 updateState = volume;
310 savedVolume = volume.intValue();
312 } else if (channelUID.getId().equals(MUTE_CHANNEL)) {
313 if (command instanceof OnOffType onOffCommand) {
314 briHandler.getClient().setMute(device, OnOffType.ON.equals(command));
315 updateState = onOffCommand;
317 } else if (channelUID.getId().equals(SLAVES_CHANNEL)) {
318 if (device instanceof Sink sink && sink.isCombinedSink()) {
319 if (command instanceof StringType) {
320 List<Sink> slaves = new ArrayList<>();
321 for (String slaveName : command.toString().split(",")) {
322 Sink slave = briHandler.getClient().getSink(slaveName.trim());
327 if (!slaves.isEmpty()) {
328 briHandler.getClient().setCombinedSinkSlaves(((Sink) device), slaves);
332 logger.warn("{} is no combined sink", device);
334 } else if (channelUID.getId().equals(ROUTE_TO_SINK_CHANNEL)) {
335 if (device instanceof SinkInput input) {
337 if (command instanceof DecimalType decimalCommand) {
338 newSink = briHandler.getClient().getSink(decimalCommand.intValue());
340 newSink = briHandler.getClient().getSink(command.toString());
342 if (newSink != null) {
343 logger.debug("rerouting {} to {}", device, newSink);
344 briHandler.getClient().moveSinkInput(input, newSink);
345 updateState = new StringType(newSink.getPaName());
347 logger.warn("no sink {} found", command.toString());
351 logger.trace("updating {} to {}", channelUID, updateState);
352 if (!updateState.equals(UnDefType.UNDEF)) {
353 updateState(channelUID, updateState);
359 * Use last checked volume for faster access
363 public Integer getLastVolume() {
364 Integer savedVolumeFinal = savedVolume;
365 if (savedVolumeFinal == null) {
366 PulseaudioBridgeHandler briHandler = getPulseaudioBridgeHandler();
367 if (briHandler != null) {
368 // refresh to get the current volume level
369 briHandler.getClient().update();
370 AbstractAudioDeviceConfig device = briHandler.getDevice(deviceIdentifier);
371 if (device != null) {
372 savedVolume = savedVolumeFinal = device.getVolume();
376 return savedVolumeFinal == null ? 50 : savedVolumeFinal;
379 public void setVolume(int volume) {
380 PulseaudioBridgeHandler briHandler = getPulseaudioBridgeHandler();
381 if (briHandler == null) {
382 logger.warn("bridge is not ready");
385 AbstractAudioDeviceConfig device = briHandler.getDevice(deviceIdentifier);
386 if (device == null) {
387 logger.warn("missing device info, aborting");
390 briHandler.getClient().setVolumePercent(device, volume);
391 updateState(VOLUME_CHANNEL, new PercentType(volume));
392 savedVolume = volume;
395 public void deviceUpdate(@Nullable AbstractAudioDeviceConfig device) {
396 if (device != null) {
397 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
398 logger.debug("Updating states of {} id: {}", device, VOLUME_CHANNEL);
399 int actualVolume = device.getVolume();
400 savedVolume = actualVolume;
401 updateState(VOLUME_CHANNEL, new PercentType(actualVolume));
402 updateState(MUTE_CHANNEL, OnOffType.from(device.isMuted()));
403 org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig.State state = device.getState();
404 updateState(STATE_CHANNEL, state != null ? new StringType(state.toString()) : new StringType("-"));
405 if (device instanceof SinkInput input) {
406 updateState(ROUTE_TO_SINK_CHANNEL,
407 new StringType(Optional.ofNullable(input.getSink()).map(Sink::getPaName).orElse("-")));
409 if (device instanceof Sink sink && sink.isCombinedSink()) {
410 updateState(SLAVES_CHANNEL, new StringType(String.join(",", sink.getCombinedSinkNames())));
415 updateState(VOLUME_CHANNEL, UnDefType.UNDEF);
416 updateState(MUTE_CHANNEL, UnDefType.UNDEF);
417 updateState(STATE_CHANNEL, UnDefType.UNDEF);
418 if (SINK_INPUT_THING_TYPE.equals(thing.getThingTypeUID())) {
419 updateState(ROUTE_TO_SINK_CHANNEL, UnDefType.UNDEF);
421 if (COMBINED_SINK_THING_TYPE.equals(thing.getThingTypeUID())) {
422 updateState(SLAVES_CHANNEL, UnDefType.UNDEF);
425 audioSourceUnsetup();
426 updateStatus(ThingStatus.OFFLINE);
430 public String getHost() {
431 Bridge bridge = getBridge();
432 if (bridge != null) {
433 return (String) bridge.getConfiguration().get(PulseaudioBindingConstants.BRIDGE_PARAMETER_HOST);
435 logger.warn("A bridge must be configured for this pulseaudio thing");
441 * This method will scan the pulseaudio server to find the port on which the module/sink/source is listening
442 * If no module is listening, then it will command the module to load on the pulse audio server,
444 * @return the port on which the pulseaudio server is listening for this sink/source
445 * @throws IOException when device info is not available
446 * @throws InterruptedException when interrupted during the loading module wait
448 public int getSimpleTcpPortAndLoadModuleIfNecessary() throws IOException, InterruptedException {
449 var briHandler = getPulseaudioBridgeHandler();
450 if (briHandler == null) {
451 throw new IOException("bridge is not ready");
453 AbstractAudioDeviceConfig device = briHandler.getDevice(deviceIdentifier);
454 if (device == null) {
455 throw new IOException(
456 "missing device info, device " + safeGetDeviceNameOrDescription() + " appears to be offline");
458 String simpleTcpPortPrefName = (device instanceof Source) ? DEVICE_PARAMETER_AUDIO_SOURCE_PORT
459 : DEVICE_PARAMETER_AUDIO_SINK_PORT;
460 BigDecimal simpleTcpPortPref = ((BigDecimal) getThing().getConfiguration().get(simpleTcpPortPrefName));
461 int simpleTcpPort = simpleTcpPortPref != null ? simpleTcpPortPref.intValue()
462 : MODULE_SIMPLE_PROTOCOL_TCP_DEFAULT_PORT;
463 String simpleFormat = ((String) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_FORMAT));
464 BigDecimal simpleRate = (BigDecimal) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_RATE);
465 BigDecimal simpleChannels = (BigDecimal) getThing().getConfiguration()
466 .get(DEVICE_PARAMETER_AUDIO_SOURCE_CHANNELS);
467 return briHandler.getClient()
468 .loadModuleSimpleProtocolTcpIfNeeded(device, simpleTcpPort, simpleFormat, simpleRate, simpleChannels)
469 .orElse(simpleTcpPort);
472 public AudioFormat getSourceAudioFormat() {
473 String simpleFormat = ((String) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_FORMAT));
474 BigDecimal simpleRate = ((BigDecimal) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_RATE));
475 BigDecimal simpleChannels = ((BigDecimal) getThing().getConfiguration()
476 .get(DEVICE_PARAMETER_AUDIO_SOURCE_CHANNELS));
477 AudioFormat fallback = new AudioFormat(AudioFormat.CONTAINER_NONE, AudioFormat.CODEC_PCM_SIGNED, false, 16,
478 16 * 16000, 16000L, 1);
479 if (simpleFormat == null || simpleRate == null || simpleChannels == null) {
482 int sampleRateAllChannels = simpleRate.intValue() * simpleChannels.intValue();
483 switch (simpleFormat) {
485 return new AudioFormat(AudioFormat.CONTAINER_NONE, AudioFormat.CODEC_PCM_UNSIGNED, null, 8,
486 8 * sampleRateAllChannels, simpleRate.longValue(), simpleChannels.intValue());
489 return new AudioFormat(AudioFormat.CONTAINER_NONE, AudioFormat.CODEC_PCM_SIGNED, false, 16,
490 16 * sampleRateAllChannels, simpleRate.longValue(), simpleChannels.intValue());
493 return new AudioFormat(AudioFormat.CONTAINER_NONE, AudioFormat.CODEC_PCM_SIGNED, true, 16,
494 16 * sampleRateAllChannels, simpleRate.longValue(), simpleChannels.intValue());
497 return new AudioFormat(AudioFormat.CONTAINER_NONE, AudioFormat.CODEC_PCM_SIGNED, false, 24,
498 24 * sampleRateAllChannels, simpleRate.longValue(), simpleChannels.intValue());
501 return new AudioFormat(AudioFormat.CONTAINER_NONE, AudioFormat.CODEC_PCM_SIGNED, true, 24,
502 24 * sampleRateAllChannels, simpleRate.longValue(), simpleChannels.intValue());
505 return new AudioFormat(AudioFormat.CONTAINER_NONE, AudioFormat.CODEC_PCM_SIGNED, false, 32,
506 32 * sampleRateAllChannels, simpleRate.longValue(), simpleChannels.intValue());
509 return new AudioFormat(AudioFormat.CONTAINER_NONE, AudioFormat.CODEC_PCM_SIGNED, true, 32,
510 32 * sampleRateAllChannels, simpleRate.longValue(), simpleChannels.intValue());
513 logger.warn("unsupported format {}", simpleFormat);
519 public int getIdleTimeout() {
520 var idleTimeout = 3000;
521 var handler = getPulseaudioBridgeHandler();
522 if (handler != null) {
523 AbstractAudioDeviceConfig device = handler.getDevice(deviceIdentifier);
524 String idleTimeoutPropName = (device instanceof Source) ? DEVICE_PARAMETER_AUDIO_SOURCE_IDLE_TIMEOUT
525 : DEVICE_PARAMETER_AUDIO_SINK_IDLE_TIMEOUT;
526 var idleTimeoutB = (BigDecimal) getThing().getConfiguration().get(idleTimeoutPropName);
527 if (idleTimeoutB != null) {
528 idleTimeout = idleTimeoutB.intValue();
534 private String safeGetDeviceNameOrDescription() {
535 DeviceIdentifier deviceIdentifierFinal = deviceIdentifier;
536 return deviceIdentifierFinal == null ? "UNKNOWN" : deviceIdentifierFinal.getNameOrDescription();
539 public int getBasicProtocolSOTimeout() {
540 var soTimeout = (BigDecimal) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOCKET_SO_TIMEOUT);
541 return soTimeout != null ? soTimeout.intValue() : 500;