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.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.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.binding.BaseThingHandler;
57 import org.openhab.core.thing.binding.ThingHandler;
58 import org.openhab.core.types.Command;
59 import org.openhab.core.types.RefreshType;
60 import org.openhab.core.types.State;
61 import org.openhab.core.types.UnDefType;
62 import org.osgi.framework.BundleContext;
63 import org.osgi.framework.ServiceRegistration;
64 import org.slf4j.Logger;
65 import org.slf4j.LoggerFactory;
68 * The {@link PulseaudioHandler} is responsible for handling commands, which are
69 * sent to one of the channels.
71 * @author Tobias Bräutigam - Initial contribution
72 * @author Miguel Álvarez - Register audio source and refactor
75 public class PulseaudioHandler extends BaseThingHandler {
77 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
78 .unmodifiableSet(Stream.of(SINK_THING_TYPE, COMBINED_SINK_THING_TYPE, SINK_INPUT_THING_TYPE,
79 SOURCE_THING_TYPE, SOURCE_OUTPUT_THING_TYPE).collect(Collectors.toSet()));
80 private final Logger logger = LoggerFactory.getLogger(PulseaudioHandler.class);
82 private @Nullable DeviceIdentifier deviceIdentifier;
83 private @Nullable PulseAudioAudioSink audioSink;
84 private @Nullable PulseAudioAudioSource audioSource;
85 private @Nullable Integer savedVolume;
87 private final Map<String, ServiceRegistration<AudioSink>> audioSinkRegistrations = new ConcurrentHashMap<>();
88 private final Map<String, ServiceRegistration<AudioSource>> audioSourceRegistrations = new ConcurrentHashMap<>();
90 private final BundleContext bundleContext;
92 public PulseaudioHandler(Thing thing, BundleContext bundleContext) {
94 this.bundleContext = bundleContext;
98 public void initialize() {
99 Configuration config = getThing().getConfiguration();
101 deviceIdentifier = new DeviceIdentifier((String) config.get(DEVICE_PARAMETER_NAME_OR_DESCRIPTION),
102 (String) config.get(DEVICE_PARAMETER_ADDITIONAL_FILTERS));
103 } catch (PatternSyntaxException p) {
104 deviceIdentifier = null;
105 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
106 "Incorrect regular expression: " + (String) config.get(DEVICE_PARAMETER_ADDITIONAL_FILTERS));
109 initializeWithTheBridge();
112 public @Nullable DeviceIdentifier getDeviceIdentifier() {
113 return deviceIdentifier;
116 private void audioSinkSetup() {
117 if (audioSink != null) {
118 // Audio sink is already setup
121 if (!SINK_THING_TYPE.equals(thing.getThingTypeUID())) {
124 // check the property to see if it's enabled :
125 Boolean sinkActivated = (Boolean) thing.getConfiguration().get(DEVICE_PARAMETER_AUDIO_SINK_ACTIVATION);
126 if (sinkActivated == null || !sinkActivated.booleanValue()) {
129 final PulseaudioHandler thisHandler = this;
130 PulseAudioAudioSink audioSink = new PulseAudioAudioSink(thisHandler, scheduler);
131 scheduler.submit(new Runnable() {
134 PulseaudioHandler.this.audioSink = audioSink;
136 audioSink.connectIfNeeded();
137 } catch (IOException e) {
138 logger.warn("pulseaudio binding cannot connect to the module-simple-protocol-tcp on {} ({})",
139 getHost(), e.getMessage());
140 } catch (InterruptedException i) {
141 logger.info("Interrupted during sink audio connection: {}", i.getMessage());
146 // Register the sink as an audio sink in openhab
147 logger.trace("Registering an audio sink for pulse audio sink thing {}", thing.getUID());
148 @SuppressWarnings("unchecked")
149 ServiceRegistration<AudioSink> reg = (ServiceRegistration<AudioSink>) bundleContext
150 .registerService(AudioSink.class.getName(), audioSink, new Hashtable<>());
151 audioSinkRegistrations.put(thing.getUID().toString(), reg);
154 private void audioSinkUnsetup() {
155 PulseAudioAudioSink sink = audioSink;
160 // Unregister the potential pulse audio sink's audio sink
161 ServiceRegistration<AudioSink> sinkReg = audioSinkRegistrations.remove(getThing().getUID().toString());
162 if (sinkReg != null) {
163 logger.trace("Unregistering the audio sync service for pulse audio sink thing {}", getThing().getUID());
164 sinkReg.unregister();
168 private void audioSourceSetup() {
169 if (audioSource != null) {
170 // Audio source is already setup
173 if (!SOURCE_THING_TYPE.equals(thing.getThingTypeUID())) {
176 // check the property to see if it's enabled :
177 Boolean sourceActivated = (Boolean) thing.getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_ACTIVATION);
178 if (sourceActivated == null || !sourceActivated.booleanValue()) {
181 final PulseaudioHandler thisHandler = this;
182 PulseAudioAudioSource audioSource = new PulseAudioAudioSource(thisHandler, scheduler);
183 scheduler.submit(new Runnable() {
186 PulseaudioHandler.this.audioSource = audioSource;
188 audioSource.connectIfNeeded();
189 } catch (IOException e) {
190 logger.warn("pulseaudio binding cannot connect to the module-simple-protocol-tcp on {} ({})",
191 getHost(), e.getMessage());
192 } catch (InterruptedException i) {
193 logger.info("Interrupted during source audio connection: {}", i.getMessage());
198 // Register the source as an audio source in openhab
199 logger.trace("Registering an audio source for pulse audio source thing {}", thing.getUID());
200 @SuppressWarnings("unchecked")
201 ServiceRegistration<AudioSource> reg = (ServiceRegistration<AudioSource>) bundleContext
202 .registerService(AudioSource.class.getName(), audioSource, new Hashtable<>());
203 audioSourceRegistrations.put(thing.getUID().toString(), reg);
206 private void audioSourceUnsetup() {
207 PulseAudioAudioSource source = audioSource;
208 if (source != null) {
212 // Unregister the potential pulse audio source's audio sources
213 ServiceRegistration<AudioSource> sourceReg = audioSourceRegistrations.remove(getThing().getUID().toString());
214 if (sourceReg != null) {
215 logger.trace("Unregistering the audio sync service for pulse audio source thing {}", getThing().getUID());
216 sourceReg.unregister();
221 public void dispose() {
222 logger.trace("Thing {} {} disposed.", getThing().getUID(), safeGetDeviceNameOrDescription());
225 audioSourceUnsetup();
229 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
230 initializeWithTheBridge();
233 private void initializeWithTheBridge() {
234 PulseaudioBridgeHandler pulseaudioBridgeHandler = getPulseaudioBridgeHandler();
235 if (pulseaudioBridgeHandler == null) {
236 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
237 } else if (pulseaudioBridgeHandler.getThing().getStatus() != ThingStatus.ONLINE) {
238 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
240 deviceUpdate(pulseaudioBridgeHandler.getDevice(deviceIdentifier));
244 private synchronized @Nullable PulseaudioBridgeHandler getPulseaudioBridgeHandler() {
245 Bridge bridge = getBridge();
246 if (bridge == null) {
247 logger.debug("Required bridge not defined for device {}.", safeGetDeviceNameOrDescription());
250 ThingHandler handler = bridge.getHandler();
251 if (handler instanceof PulseaudioBridgeHandler) {
252 return (PulseaudioBridgeHandler) handler;
254 logger.debug("No available bridge handler found for device {} bridge {} .",
255 safeGetDeviceNameOrDescription(), bridge.getUID());
261 public void handleCommand(ChannelUID channelUID, Command command) {
262 PulseaudioBridgeHandler briHandler = getPulseaudioBridgeHandler();
263 if (briHandler == null) {
264 logger.debug("pulseaudio server bridge handler not found. Cannot handle command without bridge.");
267 if (command instanceof RefreshType) {
268 briHandler.handleCommand(channelUID, command);
272 AbstractAudioDeviceConfig device = briHandler.getDevice(deviceIdentifier);
273 if (device == null) {
274 logger.warn("device {} not found", safeGetDeviceNameOrDescription());
278 State updateState = UnDefType.UNDEF;
279 if (channelUID.getId().equals(VOLUME_CHANNEL)) {
280 if (command instanceof IncreaseDecreaseType) {
281 // refresh to get the current volume level
282 briHandler.getClient().update();
283 device = briHandler.getDevice(deviceIdentifier);
284 if (device == null) {
285 logger.warn("missing device info, aborting");
288 int oldVolume = device.getVolume();
289 int newVolume = oldVolume;
290 if (command.equals(IncreaseDecreaseType.INCREASE)) {
291 newVolume = Math.min(100, oldVolume + 5);
293 if (command.equals(IncreaseDecreaseType.DECREASE)) {
294 newVolume = Math.max(0, oldVolume - 5);
296 briHandler.getClient().setVolumePercent(device, newVolume);
297 updateState = new PercentType(newVolume);
298 savedVolume = newVolume;
299 } else if (command instanceof PercentType) {
300 DecimalType volume = (DecimalType) command;
301 briHandler.getClient().setVolumePercent(device, volume.intValue());
302 updateState = (PercentType) command;
303 savedVolume = volume.intValue();
304 } else if (command instanceof DecimalType) {
306 DecimalType volume = (DecimalType) command;
307 briHandler.getClient().setVolume(device, volume.intValue());
308 updateState = (DecimalType) command;
309 savedVolume = volume.intValue();
311 } else if (channelUID.getId().equals(MUTE_CHANNEL)) {
312 if (command instanceof OnOffType) {
313 briHandler.getClient().setMute(device, OnOffType.ON.equals(command));
314 updateState = (OnOffType) command;
316 } else if (channelUID.getId().equals(SLAVES_CHANNEL)) {
317 if (device instanceof Sink && ((Sink) device).isCombinedSink()) {
318 if (command instanceof StringType) {
319 List<Sink> slaves = new ArrayList<>();
320 for (String slaveName : command.toString().split(",")) {
321 Sink slave = briHandler.getClient().getSink(slaveName.trim());
326 if (!slaves.isEmpty()) {
327 briHandler.getClient().setCombinedSinkSlaves(((Sink) device), slaves);
331 logger.warn("{} is no combined sink", device);
333 } else if (channelUID.getId().equals(ROUTE_TO_SINK_CHANNEL)) {
334 if (device instanceof SinkInput) {
336 if (command instanceof DecimalType) {
337 newSink = briHandler.getClient().getSink(((DecimalType) command).intValue());
339 newSink = briHandler.getClient().getSink(command.toString());
341 if (newSink != null) {
342 logger.debug("rerouting {} to {}", device, newSink);
343 briHandler.getClient().moveSinkInput(((SinkInput) device), newSink);
344 updateState = new StringType(newSink.getPaName());
346 logger.warn("no sink {} found", command.toString());
350 logger.trace("updating {} to {}", channelUID, updateState);
351 if (!updateState.equals(UnDefType.UNDEF)) {
352 updateState(channelUID, updateState);
358 * Use last checked volume for faster access
362 public Integer getLastVolume() {
363 Integer savedVolumeFinal = savedVolume;
364 if (savedVolumeFinal == null) {
365 PulseaudioBridgeHandler briHandler = getPulseaudioBridgeHandler();
366 if (briHandler != null) {
367 // refresh to get the current volume level
368 briHandler.getClient().update();
369 AbstractAudioDeviceConfig device = briHandler.getDevice(deviceIdentifier);
370 if (device != null) {
371 savedVolume = savedVolumeFinal = device.getVolume();
375 return savedVolumeFinal == null ? 50 : savedVolumeFinal;
378 public void setVolume(int volume) {
379 PulseaudioBridgeHandler briHandler = getPulseaudioBridgeHandler();
380 if (briHandler == null) {
381 logger.warn("bridge is not ready");
384 AbstractAudioDeviceConfig device = briHandler.getDevice(deviceIdentifier);
385 if (device == null) {
386 logger.warn("missing device info, aborting");
389 briHandler.getClient().setVolumePercent(device, volume);
390 updateState(VOLUME_CHANNEL, new PercentType(volume));
391 savedVolume = volume;
394 public void deviceUpdate(@Nullable AbstractAudioDeviceConfig device) {
395 if (device != null) {
396 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
397 logger.debug("Updating states of {} id: {}", device, VOLUME_CHANNEL);
398 int actualVolume = device.getVolume();
399 savedVolume = actualVolume;
400 updateState(VOLUME_CHANNEL, new PercentType(actualVolume));
401 updateState(MUTE_CHANNEL, OnOffType.from(device.isMuted()));
402 org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig.State state = device.getState();
403 updateState(STATE_CHANNEL, state != null ? new StringType(state.toString()) : new StringType("-"));
404 if (device instanceof SinkInput) {
405 updateState(ROUTE_TO_SINK_CHANNEL, new StringType(
406 Optional.ofNullable(((SinkInput) device).getSink()).map(Sink::getPaName).orElse("-")));
408 if (device instanceof Sink && ((Sink) device).isCombinedSink()) {
409 updateState(SLAVES_CHANNEL, new StringType(String.join(",", ((Sink) device).getCombinedSinkNames())));
414 updateState(VOLUME_CHANNEL, UnDefType.UNDEF);
415 updateState(MUTE_CHANNEL, UnDefType.UNDEF);
416 updateState(STATE_CHANNEL, UnDefType.UNDEF);
417 if (SINK_INPUT_THING_TYPE.equals(thing.getThingTypeUID())) {
418 updateState(ROUTE_TO_SINK_CHANNEL, UnDefType.UNDEF);
420 if (COMBINED_SINK_THING_TYPE.equals(thing.getThingTypeUID())) {
421 updateState(SLAVES_CHANNEL, UnDefType.UNDEF);
424 audioSourceUnsetup();
425 updateStatus(ThingStatus.OFFLINE);
429 public String getHost() {
430 Bridge bridge = getBridge();
431 if (bridge != null) {
432 return (String) bridge.getConfiguration().get(PulseaudioBindingConstants.BRIDGE_PARAMETER_HOST);
434 logger.warn("A bridge must be configured for this pulseaudio thing");
440 * This method will scan the pulseaudio server to find the port on which the module/sink/source is listening
441 * If no module is listening, then it will command the module to load on the pulse audio server,
443 * @return the port on which the pulseaudio server is listening for this sink/source
444 * @throws IOException when device info is not available
445 * @throws InterruptedException when interrupted during the loading module wait
447 public int getSimpleTcpPortAndLoadModuleIfNecessary() throws IOException, InterruptedException {
448 var briHandler = getPulseaudioBridgeHandler();
449 if (briHandler == null) {
450 throw new IOException("bridge is not ready");
452 AbstractAudioDeviceConfig device = briHandler.getDevice(deviceIdentifier);
453 if (device == null) {
454 throw new IOException(
455 "missing device info, device " + safeGetDeviceNameOrDescription() + " appears to be offline");
457 String simpleTcpPortPrefName = (device instanceof Source) ? DEVICE_PARAMETER_AUDIO_SOURCE_PORT
458 : DEVICE_PARAMETER_AUDIO_SINK_PORT;
459 BigDecimal simpleTcpPortPref = ((BigDecimal) getThing().getConfiguration().get(simpleTcpPortPrefName));
460 int simpleTcpPort = simpleTcpPortPref != null ? simpleTcpPortPref.intValue()
461 : MODULE_SIMPLE_PROTOCOL_TCP_DEFAULT_PORT;
462 String simpleFormat = ((String) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_FORMAT));
463 BigDecimal simpleRate = (BigDecimal) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_RATE);
464 BigDecimal simpleChannels = (BigDecimal) getThing().getConfiguration()
465 .get(DEVICE_PARAMETER_AUDIO_SOURCE_CHANNELS);
466 return briHandler.getClient()
467 .loadModuleSimpleProtocolTcpIfNeeded(device, simpleTcpPort, simpleFormat, simpleRate, simpleChannels)
468 .orElse(simpleTcpPort);
471 public @Nullable AudioFormat getSourceAudioFormat() {
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 if (simpleFormat == null || simpleRate == null || simpleChannels == null) {
479 switch (simpleFormat) {
481 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, null, 8, 1,
482 simpleRate.longValue(), simpleChannels.intValue());
484 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false, 16, 1,
485 simpleRate.longValue(), simpleChannels.intValue());
487 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, true, 16, 1,
488 simpleRate.longValue(), simpleChannels.intValue());
490 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false, 24, 1,
491 simpleRate.longValue(), simpleChannels.intValue());
493 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, true, 24, 1,
494 simpleRate.longValue(), simpleChannels.intValue());
496 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false, 32, 1,
497 simpleRate.longValue(), simpleChannels.intValue());
499 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, true, 32, 1,
500 simpleRate.longValue(), simpleChannels.intValue());
502 logger.warn("unsupported format {}", simpleFormat);
507 public int getIdleTimeout() {
508 var idleTimeout = 3000;
509 var handler = getPulseaudioBridgeHandler();
510 if (handler != null) {
511 AbstractAudioDeviceConfig device = handler.getDevice(deviceIdentifier);
512 String idleTimeoutPropName = (device instanceof Source) ? DEVICE_PARAMETER_AUDIO_SOURCE_IDLE_TIMEOUT
513 : DEVICE_PARAMETER_AUDIO_SINK_IDLE_TIMEOUT;
514 var idleTimeoutB = (BigDecimal) getThing().getConfiguration().get(idleTimeoutPropName);
515 if (idleTimeoutB != null) {
516 idleTimeout = idleTimeoutB.intValue();
522 private String safeGetDeviceNameOrDescription() {
523 DeviceIdentifier deviceIdentifierFinal = deviceIdentifier;
524 return deviceIdentifierFinal == null ? "UNKNOWN" : deviceIdentifierFinal.getNameOrDescription();
527 public int getBasicProtocolSOTimeout() {
528 var soTimeout = (BigDecimal) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOCKET_SO_TIMEOUT);
529 return soTimeout != null ? soTimeout.intValue() : 500;