From f7396ac712b3b824ca3ae97b4a5fb717354be733 Mon Sep 17 00:00:00 2001 From: Gwendal Roulleau Date: Fri, 2 Apr 2021 15:29:50 +0200 Subject: [PATCH] [pulseaudio] Add pulseaudio sink as openhab audio sink (#1895) This add to the pulseaudio binding the capability to use "pulseaudio sink" as an "openhab sink" to output sound from openhab to a pulse audio server on the network. You need to load module-simple-protocol-tcp sink in addition to the usual module-cli-protocol-tcp, and enable the sink in the thing configuration. Closes #1895 Signed-off-by: Gwendal Roulleau --- .../org.openhab.binding.pulseaudio/README.md | 8 +- .../org.openhab.binding.pulseaudio/pom.xml | 21 ++ .../src/main/feature/feature.xml | 3 + .../internal/PulseAudioAudioSink.java | 188 ++++++++++++++++++ .../internal/PulseaudioBindingConstants.java | 2 + .../internal/PulseaudioHandlerFactory.java | 6 +- .../handler/PulseaudioBridgeHandler.java | 4 +- .../internal/handler/PulseaudioHandler.java | 97 ++++++++- .../src/main/resources/OH-INF/thing/sink.xml | 12 ++ 9 files changed, 331 insertions(+), 10 deletions(-) create mode 100644 bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/PulseAudioAudioSink.java diff --git a/bundles/org.openhab.binding.pulseaudio/README.md b/bundles/org.openhab.binding.pulseaudio/README.md index 39b7766620dfb..b130c3482045c 100644 --- a/bundles/org.openhab.binding.pulseaudio/README.md +++ b/bundles/org.openhab.binding.pulseaudio/README.md @@ -6,7 +6,7 @@ This binding integrates pulseaudio devices. The Pulseaudio bridge is required as a "bridge" for accessing any other Pulseaudio devices. -You need a running pulseaudio server whith module **module-cli-protocol-tcp** loaded and accessible by the server which runs your openHAB instance. The following pulseaudio devices are supported: +You need a running pulseaudio server with module **module-cli-protocol-tcp** loaded and accessible by the server which runs your openHAB instance. The following pulseaudio devices are supported: * Sink * Source @@ -35,6 +35,12 @@ All devices support some of the following channels: | slaves | String | Slave sinks of a combined sink | | routeToSink | String | Shows the sink a sink-input is currently routed to | +## Audio sink + +Sink things can register themselves as audio sink in openHAB. MP3 and WAV files are supported. +Use the appropriate parameter in the sink thing to activate this possibility. +This requires the module **module-simple-protocol-tcp** loaded and accessible by the server which runs your openHAB instance. + ## Full Example ### pulseaudio.things ``` diff --git a/bundles/org.openhab.binding.pulseaudio/pom.xml b/bundles/org.openhab.binding.pulseaudio/pom.xml index 29ec07a21c6e3..a7e816c165d66 100644 --- a/bundles/org.openhab.binding.pulseaudio/pom.xml +++ b/bundles/org.openhab.binding.pulseaudio/pom.xml @@ -14,4 +14,25 @@ openHAB Add-ons :: Bundles :: Pulseaudio Binding + + + com.googlecode.soundlibs + mp3spi + 1.9.5.4 + compile + + + com.googlecode.soundlibs + jlayer + 1.0.1.4 + compile + + + com.googlecode.soundlibs + tritonus-share + 0.3.7.4 + compile + + + diff --git a/bundles/org.openhab.binding.pulseaudio/src/main/feature/feature.xml b/bundles/org.openhab.binding.pulseaudio/src/main/feature/feature.xml index afdd623480728..cd43aea257f86 100644 --- a/bundles/org.openhab.binding.pulseaudio/src/main/feature/feature.xml +++ b/bundles/org.openhab.binding.pulseaudio/src/main/feature/feature.xml @@ -7,5 +7,8 @@ openhab-transport-mdns openhab-transport-upnp mvn:org.openhab.addons.bundles/org.openhab.binding.pulseaudio/${project.version} + mvn:com.googlecode.soundlibs/tritonus-share/0.3.7.4 + mvn:com.googlecode.soundlibs/mp3spi/1.9.5.4 + mvn:com.googlecode.soundlibs/jlayer/1.0.1.4 diff --git a/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/PulseAudioAudioSink.java b/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/PulseAudioAudioSink.java new file mode 100644 index 0000000000000..8a9974d706937 --- /dev/null +++ b/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/PulseAudioAudioSink.java @@ -0,0 +1,188 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.pulseaudio.internal; + +import java.io.IOException; +import java.io.InputStream; +import java.net.Socket; +import java.net.UnknownHostException; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import javazoom.spi.mpeg.sampled.convert.MpegFormatConversionProvider; +import javazoom.spi.mpeg.sampled.file.MpegAudioFileReader; + +import javax.sound.sampled.AudioInputStream; +import javax.sound.sampled.UnsupportedAudioFileException; + +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.pulseaudio.internal.handler.PulseaudioHandler; +import org.openhab.core.audio.AudioFormat; +import org.openhab.core.audio.AudioSink; +import org.openhab.core.audio.AudioStream; +import org.openhab.core.audio.FixedLengthAudioStream; +import org.openhab.core.audio.UnsupportedAudioFormatException; +import org.openhab.core.audio.UnsupportedAudioStreamException; +import org.openhab.core.library.types.PercentType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The audio sink for openhab, implemented by a connection to a pulseaudio sink + * + * @author Gwendal Roulleau - Initial contribution + * + */ +public class PulseAudioAudioSink implements AudioSink { + + private final Logger logger = LoggerFactory.getLogger(PulseAudioAudioSink.class); + + private static final HashSet SUPPORTED_FORMATS = new HashSet<>(); + private static final HashSet> SUPPORTED_STREAMS = new HashSet<>(); + + private PulseaudioHandler pulseaudioHandler; + + private Socket clientSocket; + + static { + SUPPORTED_FORMATS.add(AudioFormat.WAV); + SUPPORTED_FORMATS.add(AudioFormat.MP3); + SUPPORTED_STREAMS.add(FixedLengthAudioStream.class); + } + + public PulseAudioAudioSink(PulseaudioHandler pulseaudioHandler) { + this.pulseaudioHandler = pulseaudioHandler; + } + + @Override + public String getId() { + return pulseaudioHandler.getThing().getUID().toString(); + } + + @Override + public @Nullable String getLabel(@Nullable Locale locale) { + return pulseaudioHandler.getThing().getLabel(); + } + + /** + * Convert MP3 to PCM, as this is the only possible format + * + * @param input + * @return + */ + private InputStream getPCMStreamFromMp3Stream(InputStream input) { + try { + MpegAudioFileReader mpegAudioFileReader = new MpegAudioFileReader(); + AudioInputStream sourceAIS = mpegAudioFileReader.getAudioInputStream(input); + javax.sound.sampled.AudioFormat sourceFormat = sourceAIS.getFormat(); + + MpegFormatConversionProvider mpegconverter = new MpegFormatConversionProvider(); + javax.sound.sampled.AudioFormat convertFormat = new javax.sound.sampled.AudioFormat( + javax.sound.sampled.AudioFormat.Encoding.PCM_SIGNED, sourceFormat.getSampleRate(), 16, + sourceFormat.getChannels(), sourceFormat.getChannels() * 2, sourceFormat.getSampleRate(), false); + + return mpegconverter.getAudioInputStream(convertFormat, sourceAIS); + + } catch (IOException | UnsupportedAudioFileException e) { + logger.error("Cannot convert this mp3 stream to pcm stream", e); + } + return null; + } + + /** + * Connect to pulseaudio with the simple protocol + * + * @throws UnknownHostException + * @throws IOException + */ + private void connectIfNeeded() throws IOException { + if (clientSocket == null || !clientSocket.isConnected() || clientSocket.isClosed()) { + String host = pulseaudioHandler.getHost(); + int port = pulseaudioHandler.getSimpleTcpPort(); + clientSocket = new Socket(host, port); + clientSocket.setSoTimeout(500); + } + } + + /** + * Disconnect the socket to pulseaudio simple protocol + */ + public void disconnect() { + if (clientSocket == null) { + try { + clientSocket.close(); + } catch (IOException e) { + } + } + } + + @Override + public void process(@Nullable AudioStream audioStream) + throws UnsupportedAudioFormatException, UnsupportedAudioStreamException { + + if (audioStream == null) { + return; + } + + InputStream audioInputStream = null; + try { + + if (AudioFormat.MP3.isCompatible(audioStream.getFormat())) { + audioInputStream = getPCMStreamFromMp3Stream(audioStream); + } else if (AudioFormat.WAV.isCompatible(audioStream.getFormat())) { + audioInputStream = audioStream; + } else { + throw new UnsupportedAudioFormatException("pulseaudio audio sink can only play pcm or mp3 stream", + audioStream.getFormat()); + } + + try { + connectIfNeeded(); + // send raw audio to the socket and to pulse audio + audioInputStream.transferTo(clientSocket.getOutputStream()); + } catch (IOException e) { + logger.error("Error while trying to send audio to pulseaudio audio sink. Cannot connect to {} : {}", + pulseaudioHandler.getHost(), pulseaudioHandler.getSimpleTcpPort()); + } + } finally { + try { + if (audioInputStream != null) { + audioInputStream.close(); + } + ; + audioStream.close(); + } catch (IOException e) { + } + } + } + + @Override + public Set getSupportedFormats() { + return SUPPORTED_FORMATS; + } + + @Override + public Set> getSupportedStreams() { + return SUPPORTED_STREAMS; + } + + @Override + public PercentType getVolume() { + return new PercentType(pulseaudioHandler.getLastVolume()); + } + + @Override + public void setVolume(PercentType volume) { + pulseaudioHandler.setVolume(volume.intValue()); + } +} diff --git a/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/PulseaudioBindingConstants.java b/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/PulseaudioBindingConstants.java index 19d17c9fec0b8..669697021e90d 100644 --- a/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/PulseaudioBindingConstants.java +++ b/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/PulseaudioBindingConstants.java @@ -51,6 +51,8 @@ public class PulseaudioBindingConstants { public static final String BRIDGE_PARAMETER_REFRESH_INTERVAL = "refresh"; public static final String DEVICE_PARAMETER_NAME = "name"; + public static final String DEVICE_PARAMETER_AUDIO_SINK_ACTIVATION = "activateSimpleProtocolSink"; + public static final String DEVICE_PARAMETER_AUDIO_SINK_PORT = "simpleProtocolSinkPort"; public static final Map TYPE_FILTERS = new HashMap<>(); diff --git a/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/PulseaudioHandlerFactory.java b/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/PulseaudioHandlerFactory.java index 4afffd91b7652..771efc9fdacf3 100644 --- a/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/PulseaudioHandlerFactory.java +++ b/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/PulseaudioHandlerFactory.java @@ -99,18 +99,22 @@ protected void removeHandler(ThingHandler thingHandler) { discoveryServiceReg.get(thingHandler).unregister(); discoveryServiceReg.remove(thingHandler); } + super.removeHandler(thingHandler); } @Override protected ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + if (PulseaudioBridgeHandler.SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID)) { PulseaudioBridgeHandler handler = new PulseaudioBridgeHandler((Bridge) thing); registerDeviceDiscoveryService(handler); return handler; } else if (PulseaudioHandler.SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID)) { - return new PulseaudioHandler(thing); + PulseaudioHandler pulseaudioHandler = new PulseaudioHandler(thing, bundleContext); + return pulseaudioHandler; } return null; diff --git a/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/handler/PulseaudioBridgeHandler.java b/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/handler/PulseaudioBridgeHandler.java index 12d56ad7d96a5..ec4f104c18bf4 100644 --- a/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/handler/PulseaudioBridgeHandler.java +++ b/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/handler/PulseaudioBridgeHandler.java @@ -155,7 +155,9 @@ public void initialize() { @Override public void dispose() { - pollingJob.cancel(true); + if (pollingJob != null) { + pollingJob.cancel(true); + } client.disconnect(); super.dispose(); } diff --git a/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/handler/PulseaudioHandler.java b/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/handler/PulseaudioHandler.java index 1b8f1d63a52a0..d5161735c87c3 100644 --- a/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/handler/PulseaudioHandler.java +++ b/bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/handler/PulseaudioHandler.java @@ -14,18 +14,25 @@ import static org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants.*; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; +import java.util.Hashtable; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.openhab.binding.pulseaudio.internal.PulseAudioAudioSink; +import org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants; import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig; import org.openhab.binding.pulseaudio.internal.items.Sink; import org.openhab.binding.pulseaudio.internal.items.SinkInput; +import org.openhab.core.audio.AudioSink; import org.openhab.core.config.core.Configuration; import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.IncreaseDecreaseType; @@ -44,6 +51,8 @@ import org.openhab.core.types.RefreshType; import org.openhab.core.types.State; import org.openhab.core.types.UnDefType; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceRegistration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -68,8 +77,17 @@ public class PulseaudioHandler extends BaseThingHandler implements DeviceStatusL private String name; - public PulseaudioHandler(Thing thing) { + private PulseAudioAudioSink audioSink; + + private Integer savedVolume; + + private Map> audioSinkRegistrations = new ConcurrentHashMap<>(); + + private BundleContext bundleContext; + + public PulseaudioHandler(Thing thing, BundleContext bundleContext) { super(thing); + this.bundleContext = bundleContext; } @Override @@ -80,6 +98,23 @@ public void initialize() { // until we get an update put the Thing offline updateStatus(ThingStatus.OFFLINE); deviceOnlineWatchdog(); + + // if it's a SINK thing, then maybe we have to activate the audio sink + if (PulseaudioBindingConstants.SINK_THING_TYPE.equals(thing.getThingTypeUID())) { + // check the property to see if we it's enabled : + Boolean sinkActivated = (Boolean) thing.getConfiguration() + .get(PulseaudioBindingConstants.DEVICE_PARAMETER_AUDIO_SINK_ACTIVATION); + if (sinkActivated != null && sinkActivated) { + // Register the sink as an audio sink in openhab + logger.trace("Registering an audio sink for pulse audio sink thing {}", thing.getUID()); + PulseAudioAudioSink audioSink = new PulseAudioAudioSink(this); + setAudioSink(audioSink); + @SuppressWarnings("unchecked") + ServiceRegistration reg = (ServiceRegistration) bundleContext + .registerService(AudioSink.class.getName(), audioSink, new Hashtable<>()); + audioSinkRegistrations.put(thing.getUID().toString(), reg); + } + } } @Override @@ -92,6 +127,13 @@ public void dispose() { bridgeHandler = null; logger.trace("Thing {} {} disposed.", getThing().getUID(), name); super.dispose(); + + // Unregister the potential pulse audio sink's audio sink + ServiceRegistration reg = audioSinkRegistrations.get(getThing().getUID().toString()); + if (reg != null) { + logger.trace("Unregistering the audio sync service for pulse audio sink thing {}", getThing().getUID()); + reg.unregister(); + } } private void deviceOnlineWatchdog() { @@ -162,15 +204,15 @@ public void handleCommand(ChannelUID channelUID, Command command) { // refresh to get the current volume level bridge.getClient().update(); device = bridge.getDevice(name); - int volume = device.getVolume(); + savedVolume = device.getVolume(); if (command.equals(IncreaseDecreaseType.INCREASE)) { - volume = Math.min(100, volume + 5); + savedVolume = Math.min(100, savedVolume + 5); } if (command.equals(IncreaseDecreaseType.DECREASE)) { - volume = Math.max(0, volume - 5); + savedVolume = Math.max(0, savedVolume - 5); } - bridge.getClient().setVolumePercent(device, volume); - updateState = new PercentType(volume); + bridge.getClient().setVolumePercent(device, savedVolume); + updateState = new PercentType(savedVolume); } else if (command instanceof PercentType) { DecimalType volume = (DecimalType) command; bridge.getClient().setVolumePercent(device, volume.intValue()); @@ -227,12 +269,37 @@ public void handleCommand(ChannelUID channelUID, Command command) { } } + /** + * Use last checked volume for faster access + * + * @return + */ + public int getLastVolume() { + if (savedVolume == null) { + PulseaudioBridgeHandler bridge = getPulseaudioBridgeHandler(); + AbstractAudioDeviceConfig device = bridge.getDevice(name); + // refresh to get the current volume level + bridge.getClient().update(); + device = bridge.getDevice(name); + savedVolume = device.getVolume(); + } + return savedVolume == null ? 50 : savedVolume; + } + + public void setVolume(int volume) { + PulseaudioBridgeHandler bridge = getPulseaudioBridgeHandler(); + AbstractAudioDeviceConfig device = bridge.getDevice(name); + bridge.getClient().setVolumePercent(device, volume); + updateState(VOLUME_CHANNEL, new PercentType(volume)); + } + @Override public void onDeviceStateChanged(ThingUID bridge, AbstractAudioDeviceConfig device) { if (device.getPaName().equals(name)) { updateStatus(ThingStatus.ONLINE); logger.debug("Updating states of {} id: {}", device, VOLUME_CHANNEL); - updateState(VOLUME_CHANNEL, new PercentType(device.getVolume())); + savedVolume = device.getVolume(); + updateState(VOLUME_CHANNEL, new PercentType(savedVolume)); updateState(MUTE_CHANNEL, device.isMuted() ? OnOffType.ON : OnOffType.OFF); updateState(STATE_CHANNEL, device.getState() != null ? new StringType(device.getState().toString()) : new StringType("-")); @@ -248,11 +315,23 @@ public void onDeviceStateChanged(ThingUID bridge, AbstractAudioDeviceConfig devi } } + @SuppressWarnings("null") + public String getHost() { + return (String) getBridge().getConfiguration().get(PulseaudioBindingConstants.BRIDGE_PARAMETER_HOST); + } + + public int getSimpleTcpPort() { + return ((BigDecimal) getThing().getConfiguration() + .get(PulseaudioBindingConstants.DEVICE_PARAMETER_AUDIO_SINK_PORT)).intValue(); + } + @Override public void onDeviceRemoved(PulseaudioBridgeHandler bridge, AbstractAudioDeviceConfig device) { if (device.getPaName().equals(name)) { bridgeHandler.unregisterDeviceStatusListener(this); bridgeHandler = null; + audioSink.disconnect(); + audioSink = null; updateStatus(ThingStatus.OFFLINE); } } @@ -261,4 +340,8 @@ public void onDeviceRemoved(PulseaudioBridgeHandler bridge, AbstractAudioDeviceC public void onDeviceAdded(Bridge bridge, AbstractAudioDeviceConfig device) { logger.trace("new device discovered {} by {}", device, bridge); } + + public void setAudioSink(PulseAudioAudioSink audioSink) { + this.audioSink = audioSink; + } } diff --git a/bundles/org.openhab.binding.pulseaudio/src/main/resources/OH-INF/thing/sink.xml b/bundles/org.openhab.binding.pulseaudio/src/main/resources/OH-INF/thing/sink.xml index 75a2eb06ca95e..40d7b257dc4c0 100644 --- a/bundles/org.openhab.binding.pulseaudio/src/main/resources/OH-INF/thing/sink.xml +++ b/bundles/org.openhab.binding.pulseaudio/src/main/resources/OH-INF/thing/sink.xml @@ -21,6 +21,18 @@ The name of one specific device. + + + Activation of a corresponding sink in openHAB (needs module-simple-protocol-tcp loaded on the server) + false + false + + + + Port of the module-simple-protocol-tcp listening for this sink + 4711 + false +