[pulseaudio] Add pulseaudio sink as openhab audio sink (#1895) (#10423)

* [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 <gwendal.roulleau@gmail.com>

* Small corrections after review

And getting rid of some other compilation warnings
Signed-off-by: Gwendal Roulleau <gwendal.roulleau@gmail.com>

* Fix some registration errors  and allow the binding to load the simple module remotely

Signed-off-by: Gwendal Roulleau <gwendal.roulleau@gmail.com>

* Small corrections after reviews

initialize audiosink in a thread with scheduler.submit
clear some warning related code.

Signed-off-by: Gwendal Roulleau <gwendal.roulleau@gmail.com>
Better interruptexception handling

* Fix two small concurrency bugs

Signed-off-by: Gwendal Roulleau <gwendal.roulleau@gmail.com>

Co-authored-by: Gwendal Roulleau <gwendal.roulleau@gmail.com>
This commit is contained in:
dalgwen 2021-04-09 22:44:38 +02:00 committed by GitHub
parent 89d735bb0f
commit c3b29e0fe6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 502 additions and 40 deletions

View File

@ -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,12 +35,18 @@ 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 (activateSimpleProtocolSink).
This requires the module **module-simple-protocol-tcp** to be present on the server which runs your openHAB instance. The binding will try to command (if not discovered first) the load of this module on the pulseaudio server.
## Full Example
### pulseaudio.things
```
Bridge pulseaudio:bridge:<bridgname> "<Bridge Label>" @ "<Room>" [ host="<ipAddress>", port=4712 ] {
Things:
Thing sink multiroom "Snapcast" @ "Room" [name="alsa_card.pci-0000_00_1f.3"] // this name corresponds to pactl list-sinks output
Thing sink multiroom "Snapcast" @ "Room" [name="alsa_card.pci-0000_00_1f.3", activateSimpleProtocolSink="true", simpleProtocolSinkPort="4711"] // the name corresponds to pactl list-sinks output
Thing source microphone "microphone" @ "Room" [name="alsa_input.pci-0000_00_14.2.analog-stereo"]
Thing sink-input openhabTTS "OH-Voice" @ "Room" [name="alsa_output.pci-0000_00_1f.3.hdmi-stereo-extra1"]
Thing source-output remotePulseSink "Other Room Speaker" @ "Other Room" [name="alsa_input.pci-0000_00_14.2.analog-stereo"]

View File

@ -14,4 +14,25 @@
<name>openHAB Add-ons :: Bundles :: Pulseaudio Binding</name>
<dependencies>
<dependency>
<groupId>com.googlecode.soundlibs</groupId>
<artifactId>mp3spi</artifactId>
<version>1.9.5.4</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.googlecode.soundlibs</groupId>
<artifactId>jlayer</artifactId>
<version>1.0.1.4</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.googlecode.soundlibs</groupId>
<artifactId>tritonus-share</artifactId>
<version>0.3.7.4</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View File

@ -7,5 +7,8 @@
<feature>openhab-transport-mdns</feature>
<feature>openhab-transport-upnp</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.pulseaudio/${project.version}</bundle>
<bundle dependency="true">mvn:com.googlecode.soundlibs/tritonus-share/0.3.7.4</bundle>
<bundle dependency="true">mvn:com.googlecode.soundlibs/mp3spi/1.9.5.4</bundle>
<bundle dependency="true">mvn:com.googlecode.soundlibs/jlayer/1.0.1.4</bundle>
</feature>
</features>

View File

@ -0,0 +1,205 @@
/**
* 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.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.NonNullByDefault;
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
*
*/
@NonNullByDefault
public class PulseAudioAudioSink implements AudioSink {
private final Logger logger = LoggerFactory.getLogger(PulseAudioAudioSink.class);
private static final HashSet<AudioFormat> SUPPORTED_FORMATS = new HashSet<>();
private static final HashSet<Class<? extends AudioStream>> SUPPORTED_STREAMS = new HashSet<>();
private PulseaudioHandler pulseaudioHandler;
private @Nullable 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 @Nullable 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.warn("Cannot convert this mp3 stream to pcm stream: {}", e.getMessage());
}
return null;
}
/**
* Connect to pulseaudio with the simple protocol
*
* @throws IOException
* @throws InterruptedException when interrupted during the loading module wait
*/
public void connectIfNeeded() throws IOException, InterruptedException {
Socket clientSocketLocal = clientSocket;
if (clientSocketLocal == null || !clientSocketLocal.isConnected() || clientSocketLocal.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());
}
for (int countAttempt = 1; countAttempt <= 2; countAttempt++) { // two attempts allowed
try {
connectIfNeeded();
if (audioInputStream != null && clientSocket != null) {
// send raw audio to the socket and to pulse audio
audioInputStream.transferTo(clientSocket.getOutputStream());
break;
}
} catch (IOException e) {
disconnect(); // disconnect force to clear connection in case of socket not cleanly shutdown
if (countAttempt == 2) { // we won't retry : log and quit
if (logger.isWarnEnabled()) {
String port = clientSocket != null ? Integer.toString(clientSocket.getPort()) : "unknown";
logger.warn(
"Error while trying to send audio to pulseaudio audio sink. Cannot connect to {}:{}, error: {}",
pulseaudioHandler.getHost(), port, e.getMessage());
}
break;
}
} catch (InterruptedException ie) {
logger.info("Interrupted during sink audio connection: {}", ie.getMessage());
break;
}
}
} finally {
try {
if (audioInputStream != null) {
audioInputStream.close();
}
audioStream.close();
} catch (IOException e) {
}
}
}
@Override
public Set<AudioFormat> getSupportedFormats() {
return SUPPORTED_FORMATS;
}
@Override
public Set<Class<? extends AudioStream>> getSupportedStreams() {
return SUPPORTED_STREAMS;
}
@Override
public PercentType getVolume() {
return new PercentType(pulseaudioHandler.getLastVolume());
}
@Override
public void setVolume(PercentType volume) {
pulseaudioHandler.setVolume(volume.intValue());
}
}

View File

@ -51,6 +51,11 @@ 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 String MODULE_SIMPLE_PROTOCOL_TCP_NAME = "module-simple-protocol-tcp";
public static final int MODULE_SIMPLE_PROTOCOL_TCP_DEFAULT_PORT = 4711;
public static final Map<String, Boolean> TYPE_FILTERS = new HashMap<>();

View File

@ -24,7 +24,10 @@ import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import org.eclipse.jdt.annotation.NonNull;
import org.openhab.binding.pulseaudio.internal.cli.Parser;
import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig;
import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig.State;
@ -138,28 +141,30 @@ public class PulseaudioClient {
/**
* updates the item states and their relationships
*/
public void update() {
modules.clear();
modules.addAll(Parser.parseModules(listModules()));
public synchronized void update() {
// one step copy
modules = new ArrayList<Module>(Parser.parseModules(listModules()));
items.clear();
if (TYPE_FILTERS.get(SINK_THING_TYPE.getId())) {
List<AbstractAudioDeviceConfig> newItems = new ArrayList<>(); // prepare new list before assigning it
newItems.clear();
if (Optional.ofNullable(TYPE_FILTERS.get(SINK_THING_TYPE.getId())).orElse(false)) {
logger.debug("reading sinks");
items.addAll(Parser.parseSinks(listSinks(), this));
newItems.addAll(Parser.parseSinks(listSinks(), this));
}
if (TYPE_FILTERS.get(SOURCE_THING_TYPE.getId())) {
if (Optional.ofNullable(TYPE_FILTERS.get(SOURCE_THING_TYPE.getId())).orElse(false)) {
logger.debug("reading sources");
items.addAll(Parser.parseSources(listSources(), this));
newItems.addAll(Parser.parseSources(listSources(), this));
}
if (TYPE_FILTERS.get(SINK_INPUT_THING_TYPE.getId())) {
if (Optional.ofNullable(TYPE_FILTERS.get(SINK_INPUT_THING_TYPE.getId())).orElse(false)) {
logger.debug("reading sink-inputs");
items.addAll(Parser.parseSinkInputs(listSinkInputs(), this));
newItems.addAll(Parser.parseSinkInputs(listSinkInputs(), this));
}
if (TYPE_FILTERS.get(SOURCE_OUTPUT_THING_TYPE.getId())) {
if (Optional.ofNullable(TYPE_FILTERS.get(SOURCE_OUTPUT_THING_TYPE.getId())).orElse(false)) {
logger.debug("reading source-outputs");
items.addAll(Parser.parseSourceOutputs(listSourceOutputs(), this));
newItems.addAll(Parser.parseSourceOutputs(listSourceOutputs(), this));
}
logger.debug("Pulseaudio server {}: {} modules and {} items updated", host, modules.size(), items.size());
logger.debug("Pulseaudio server {}: {} modules and {} items updated", host, modules.size(), newItems.size());
items = newItems;
}
private String listModules() {
@ -377,6 +382,74 @@ public class PulseaudioClient {
item.setVolume(Math.round(100f / 65536f * vol));
}
/**
* Locate or load (if needed) the simple protocol tcp module for the given sink
* and returns the port.
* The module loading (if needed) will be tried several times, on a new random port each time.
*
* @param item the sink we are searching for
* @param simpleTcpPortPref the port to use if we have to load the module
* @return the port on which the module is listening
* @throws InterruptedException
*/
public Optional<Integer> loadModuleSimpleProtocolTcpIfNeeded(AbstractAudioDeviceConfig item,
Integer simpleTcpPortPref) throws InterruptedException {
int currentTry = 0;
int simpleTcpPortToTry = simpleTcpPortPref;
do {
Optional<Integer> simplePort = findSimpleProtocolTcpModule(item);
if (simplePort.isPresent()) {
return simplePort;
} else {
sendRawCommand("load-module module-simple-protocol-tcp sink=" + item.getPaName() + " port="
+ simpleTcpPortToTry);
simpleTcpPortToTry = new Random().nextInt(64512) + 1024; // a random port above 1024
}
Thread.sleep(100);
currentTry++;
} while (currentTry < 3);
logger.warn("The pulseaudio binding tried 3 times to load the module-simple-protocol-tcp"
+ " on random port on the pulseaudio server and give up trying");
return Optional.empty();
}
/**
* Find a simple protocol module corresponding to the given sink in argument
* and returns the port it listens to
*
* @param item
* @return
*/
private Optional<Integer> findSimpleProtocolTcpModule(AbstractAudioDeviceConfig item) {
update();
List<Module> modulesCopy = new ArrayList<Module>(modules);
return modulesCopy.stream() // iteration on modules
.filter(module -> MODULE_SIMPLE_PROTOCOL_TCP_NAME.equals(module.getPaName())) // filter on module name
.filter(module -> extractArgumentFromLine("sink", module.getArgument()) // extract sink in argument
.map(sinkName -> sinkName.equals(item.getPaName())).orElse(false)) // filter on sink name
.findAny() // get a corresponding module
.map(module -> extractArgumentFromLine("port", module.getArgument())
.orElse(Integer.toString(MODULE_SIMPLE_PROTOCOL_TCP_DEFAULT_PORT))) // get port
.map(portS -> Integer.parseInt(portS));
}
private @NonNull Optional<@NonNull String> extractArgumentFromLine(String argumentWanted, String argumentLine) {
String argument = null;
int startPortIndex = argumentLine.indexOf(argumentWanted + "=");
if (startPortIndex != -1) {
startPortIndex = startPortIndex + argumentWanted.length() + 1;
int endPortIndex = argumentLine.indexOf(" ", startPortIndex);
if (endPortIndex == -1) {
endPortIndex = argumentLine.length();
}
argument = argumentLine.substring(startPortIndex, endPortIndex);
}
return Optional.ofNullable(argument);
}
/**
* returns the item names that can be used in commands
*
@ -404,13 +477,14 @@ public class PulseaudioClient {
* values from 0 - 100)
*/
public void setVolumePercent(AbstractAudioDeviceConfig item, int vol) {
int volumeToSet = vol;
if (item == null) {
return;
}
if (vol <= 100) {
vol = toAbsoluteVolume(vol);
if (volumeToSet <= 100) {
volumeToSet = toAbsoluteVolume(volumeToSet);
}
setVolume(item, vol);
setVolume(item, volumeToSet);
}
/**
@ -583,6 +657,8 @@ public class PulseaudioClient {
} catch (SocketTimeoutException e) {
// Timeout -> as newer PA versions (>=5.0) do not send the >>> we have no chance
// to detect the end of the answer, except by this timeout
} catch (SocketException e) {
logger.warn("Socket exception while sending pulseaudio command: {}", e.getMessage());
} catch (IOException e) {
logger.error("Exception while reading socket: {}", e.getMessage());
}
@ -619,7 +695,7 @@ public class PulseaudioClient {
} catch (NoRouteToHostException e) {
logger.error("no route to host {}", host);
} catch (SocketException e) {
logger.error("{}", e.getLocalizedMessage(), e);
logger.error("cannot connect to host {} : {}", host, e.getMessage());
}
}

View File

@ -92,25 +92,28 @@ public class PulseaudioHandlerFactory extends BaseThingHandlerFactory {
@Override
protected void removeHandler(ThingHandler thingHandler) {
if (this.discoveryServiceReg.containsKey(thingHandler)) {
ServiceRegistration<?> serviceRegistration = this.discoveryServiceReg.get(thingHandler);
if (serviceRegistration != null) {
PulseaudioDeviceDiscoveryService service = (PulseaudioDeviceDiscoveryService) bundleContext
.getService(discoveryServiceReg.get(thingHandler).getReference());
.getService(serviceRegistration.getReference());
service.deactivate();
discoveryServiceReg.get(thingHandler).unregister();
discoveryServiceReg.remove(thingHandler);
serviceRegistration.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);
return new PulseaudioHandler(thing, bundleContext);
}
return null;

View File

@ -135,15 +135,18 @@ public class Parser {
}
}
if (properties.containsKey("muted")) {
sink.setMuted(properties.get("muted").equalsIgnoreCase("yes"));
sink.setMuted("yes".equalsIgnoreCase(properties.get("muted")));
}
if (properties.containsKey("volume")) {
sink.setVolume(Integer.valueOf(parseVolume(properties.get("volume"))));
}
if (properties.containsKey("combine.slaves")) {
// this is a combined sink, the combined sink object should be
for (String sinkName : properties.get("combine.slaves").replace("\"", "").split(",")) {
sink.addCombinedSinkName(sinkName);
String sinkNames = properties.get("combine.slaves");
if (sinkNames != null) {
for (String sinkName : sinkNames.replace("\"", "").split(",")) {
sink.addCombinedSinkName(sinkName);
}
}
combinedSinks.add(sink);
}
@ -203,7 +206,7 @@ public class Parser {
}
}
if (properties.containsKey("muted")) {
item.setMuted(properties.get("muted").equalsIgnoreCase("yes"));
item.setMuted("yes".equalsIgnoreCase(properties.get("muted")));
}
if (properties.containsKey("volume")) {
item.setVolume(Integer.valueOf(parseVolume(properties.get("volume"))));
@ -262,7 +265,7 @@ public class Parser {
}
}
if (properties.containsKey("muted")) {
source.setMuted(properties.get("muted").equalsIgnoreCase("yes"));
source.setMuted("yes".equalsIgnoreCase(properties.get("muted")));
}
if (properties.containsKey("volume")) {
source.setVolume(parseVolume(properties.get("volume")));
@ -322,7 +325,7 @@ public class Parser {
}
}
if (properties.containsKey("muted")) {
item.setMuted(properties.get("muted").equalsIgnoreCase("yes"));
item.setMuted("yes".equalsIgnoreCase(properties.get("muted")));
}
if (properties.containsKey("volume")) {
item.setVolume(Integer.valueOf(parseVolume(properties.get("volume"))));

View File

@ -73,7 +73,6 @@ public class PulseaudioDiscoveryParticipant implements MDNSDiscoveryParticipant
}
return result;
} catch (IOException e) {
result = null;
}
}
return result;

View File

@ -155,8 +155,12 @@ public class PulseaudioBridgeHandler extends BaseBridgeHandler {
@Override
public void dispose() {
pollingJob.cancel(true);
client.disconnect();
if (pollingJob != null) {
pollingJob.cancel(true);
}
if (client != null) {
client.disconnect();
}
super.dispose();
}

View File

@ -14,18 +14,26 @@ package org.openhab.binding.pulseaudio.internal.handler;
import static org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants.*;
import java.io.IOException;
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 +52,8 @@ import org.openhab.core.types.Command;
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 +78,17 @@ public class PulseaudioHandler extends BaseThingHandler implements DeviceStatusL
private String name;
public PulseaudioHandler(Thing thing) {
private PulseAudioAudioSink audioSink;
private Integer savedVolume;
private Map<String, ServiceRegistration<AudioSink>> audioSinkRegistrations = new ConcurrentHashMap<>();
private BundleContext bundleContext;
public PulseaudioHandler(Thing thing, BundleContext bundleContext) {
super(thing);
this.bundleContext = bundleContext;
}
@Override
@ -80,6 +99,42 @@ public class PulseaudioHandler extends BaseThingHandler implements DeviceStatusL
// 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) {
audioSinkSetup();
}
}
}
private void audioSinkSetup() {
final PulseaudioHandler thisHandler = this;
scheduler.submit(new Runnable() {
@Override
public void run() {
// 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(thisHandler);
setAudioSink(audioSink);
try {
audioSink.connectIfNeeded();
} catch (IOException e) {
logger.warn("pulseaudio binding cannot connect to the module-simple-protocol-tcp on {} ({})",
getHost(), e.getMessage());
} catch (InterruptedException i) {
logger.info("Interrupted during sink audio connection: {}", i.getMessage());
return;
}
@SuppressWarnings("unchecked")
ServiceRegistration<AudioSink> reg = (ServiceRegistration<AudioSink>) bundleContext
.registerService(AudioSink.class.getName(), audioSink, new Hashtable<>());
audioSinkRegistrations.put(thing.getUID().toString(), reg);
}
});
}
@Override
@ -89,9 +144,21 @@ public class PulseaudioHandler extends BaseThingHandler implements DeviceStatusL
refreshJob = null;
}
updateStatus(ThingStatus.OFFLINE);
bridgeHandler.unregisterDeviceStatusListener(this);
bridgeHandler = null;
logger.trace("Thing {} {} disposed.", getThing().getUID(), name);
super.dispose();
if (audioSink != null) {
audioSink.disconnect();
}
// Unregister the potential pulse audio sink's audio sink
ServiceRegistration<AudioSink> reg = audioSinkRegistrations.remove(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 +229,15 @@ public class PulseaudioHandler extends BaseThingHandler implements DeviceStatusL
// 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 +294,37 @@ public class PulseaudioHandler extends BaseThingHandler implements DeviceStatusL
}
}
/**
* 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 +340,40 @@ public class PulseaudioHandler extends BaseThingHandler implements DeviceStatusL
}
}
public String getHost() {
Bridge bridge = getBridge();
if (bridge != null) {
return (String) bridge.getConfiguration().get(PulseaudioBindingConstants.BRIDGE_PARAMETER_HOST);
} else {
logger.error("A bridge must be configured for this pulseaudio thing");
return "null";
}
}
/**
* This method will scan the pulseaudio server to find the port on which the module/sink is listening
* If no module is listening, then it will command the module to load on the pulse audio server,
*
* @return the port on which the pulseaudio server is listening for this sink
* @throws InterruptedException when interrupted during the loading module wait
*/
public int getSimpleTcpPort() throws InterruptedException {
Integer simpleTcpPortPref = ((BigDecimal) getThing().getConfiguration()
.get(PulseaudioBindingConstants.DEVICE_PARAMETER_AUDIO_SINK_PORT)).intValue();
PulseaudioBridgeHandler bridgeHandler = getPulseaudioBridgeHandler();
AbstractAudioDeviceConfig device = bridgeHandler.getDevice(name);
return getPulseaudioBridgeHandler().getClient().loadModuleSimpleProtocolTcpIfNeeded(device, simpleTcpPortPref)
.orElse(simpleTcpPortPref);
}
@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 +382,8 @@ public class PulseaudioHandler extends BaseThingHandler implements DeviceStatusL
public void onDeviceAdded(Bridge bridge, AbstractAudioDeviceConfig device) {
logger.trace("new device discovered {} by {}", device, bridge);
}
public void setAudioSink(PulseAudioAudioSink audioSink) {
this.audioSink = audioSink;
}
}

View File

@ -10,6 +10,7 @@
</supported-bridge-type-refs>
<label>A Pulseaudio Sink</label>
<description>represents a pulseaudio sink</description>
<category>Speaker</category>
<channels>
<channel id="volume" typeId="volume"/>
@ -21,6 +22,17 @@
<label>Name</label>
<description>The name of one specific device.</description>
</parameter>
<parameter name="activateSimpleProtocolSink" type="boolean" required="false">
<label>Create an Audio Sink with simple-protocol-tcp</label>
<description>Activation of a corresponding sink in OpenHAB (module-simple-protocol-tcp must be available on the
pulseaudio server)</description>
<default>false</default>
</parameter>
<parameter name="simpleProtocolSinkPort" type="integer" required="false">
<label>Simple Protocol Port</label>
<description>Default Port to allocate for use by module-simple-protocol-tcp on the pulseaudio server</description>
<default>4711</default>
</parameter>
</config-description>
</thing-type>