]> git.basschouten.com Git - openhab-addons.git/commitdiff
[pulseaudio] Add pulseaudio sink as openhab audio sink (#1895) (#10423)
authordalgwen <dalgwen@users.noreply.github.com>
Fri, 9 Apr 2021 20:44:38 +0000 (22:44 +0200)
committerGitHub <noreply@github.com>
Fri, 9 Apr 2021 20:44:38 +0000 (22:44 +0200)
* [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>
12 files changed:
bundles/org.openhab.binding.pulseaudio/README.md
bundles/org.openhab.binding.pulseaudio/pom.xml
bundles/org.openhab.binding.pulseaudio/src/main/feature/feature.xml
bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/PulseAudioAudioSink.java [new file with mode: 0644]
bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/PulseaudioBindingConstants.java
bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/PulseaudioClient.java
bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/PulseaudioHandlerFactory.java
bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/cli/Parser.java
bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/discovery/PulseaudioDiscoveryParticipant.java
bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/handler/PulseaudioBridgeHandler.java
bundles/org.openhab.binding.pulseaudio/src/main/java/org/openhab/binding/pulseaudio/internal/handler/PulseaudioHandler.java
bundles/org.openhab.binding.pulseaudio/src/main/resources/OH-INF/thing/sink.xml

index 39b7766620dfb0523092e71aaf7fd7a877378bff..8951f9d18b8721bb112c814e24a819509641aef5 100644 (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"]
index 29ec07a21c6e361e43f995e471812ab579f6d43e..efc42fa11186b4f148eca6bcdc8b24961294f8ce 100644 (file)
 
   <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>
index afdd62348072825c6752d0b0d279fab1681fc780..cd43aea257f86d8b8c87d5768b0c55f4c512201d 100644 (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>
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 (file)
index 0000000..d1fe070
--- /dev/null
@@ -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());
+    }
+}
index 19d17c9fec0b8a523ba5a05dd566150b77dd6951..9c8f4f8521927df1e96e6e28836c48c5efce3e00 100644 (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<>();
 
index 80c61f6fae23013e11cee25f5658ee60998a0e9e..54cbc552b2a213093cb09644dc421ac01f0e2849 100644 (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());
         }
     }
 
index 4afffd91b76529b4f50407cc521fe64b4ba254ab..06c32ab26605d9ef35ea90df704ae38ae62b426f 100644 (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;
index 68905acc44c39dcc9d137ad421a7a19027bdb49c..9675d4400f790b139809b6f742bf317743a82591 100644 (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"))));
index 86ffa16c99bda24466644170c0307bb596f368ed..3a7a24fe3891c63c8243c199f5999d15e2431b1e 100644 (file)
@@ -73,7 +73,6 @@ public class PulseaudioDiscoveryParticipant implements MDNSDiscoveryParticipant
                 }
                 return result;
             } catch (IOException e) {
-                result = null;
             }
         }
         return result;
index 12d56ad7d96a5eabae5948273dd59c635a46be23..c91ecfe2c55de7292b26f76c46b57a28e5f219d2 100644 (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();
     }
 
index 1b8f1d63a52a09f50c8fd8e0ef43ec8cae53c2d3..88690998ac819c164be260c95a2021a7b089493e 100644 (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;
+    }
 }
index 75a2eb06ca95e13c402476195dbfde9f94c22c1a..9f98aca6ace7fcdc85ef51cd90a1ccc926b9537d 100644 (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"/>
                                <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>