2 * Copyright (c) 2010-2021 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;
15 import static org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants.*;
17 import java.io.IOException;
18 import java.io.InputStream;
19 import java.io.PrintStream;
20 import java.net.NoRouteToHostException;
21 import java.net.Socket;
22 import java.net.SocketException;
23 import java.net.SocketTimeoutException;
24 import java.net.UnknownHostException;
25 import java.util.ArrayList;
26 import java.util.List;
27 import java.util.Optional;
28 import java.util.Random;
30 import org.eclipse.jdt.annotation.NonNull;
31 import org.openhab.binding.pulseaudio.internal.cli.Parser;
32 import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig;
33 import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig.State;
34 import org.openhab.binding.pulseaudio.internal.items.Module;
35 import org.openhab.binding.pulseaudio.internal.items.Sink;
36 import org.openhab.binding.pulseaudio.internal.items.SinkInput;
37 import org.openhab.binding.pulseaudio.internal.items.Source;
38 import org.openhab.binding.pulseaudio.internal.items.SourceOutput;
39 import org.slf4j.Logger;
40 import org.slf4j.LoggerFactory;
43 * The client connects to a pulseaudio server via TCP. It reads the current state of the
44 * pulseaudio server (available sinks, sources,...) and can send commands to the server.
45 * The syntax of the commands is the same as for the pactl command line tool provided by pulseaudio.
47 * On the pulseaudio server the module-cli-protocol-tcp has to be loaded.
49 * @author Tobias Bräutigam - Initial contribution
51 public class PulseaudioClient {
53 private final Logger logger = LoggerFactory.getLogger(PulseaudioClient.class);
57 private Socket client;
59 private List<AbstractAudioDeviceConfig> items;
60 private List<Module> modules;
63 * corresponding name to execute actions on sink items
65 private static final String ITEM_SINK = "sink";
68 * corresponding name to execute actions on source items
70 private static final String ITEM_SOURCE = "source";
73 * corresponding name to execute actions on sink-input items
75 private static final String ITEM_SINK_INPUT = "sink-input";
78 * corresponding name to execute actions on source-output items
80 private static final String ITEM_SOURCE_OUTPUT = "source-output";
83 * command to list the loaded modules
85 private static final String CMD_LIST_MODULES = "list-modules";
88 * command to list the sinks
90 private static final String CMD_LIST_SINKS = "list-sinks";
93 * command to list the sources
95 private static final String CMD_LIST_SOURCES = "list-sources";
98 * command to list the sink-inputs
100 private static final String CMD_LIST_SINK_INPUTS = "list-sink-inputs";
103 * command to list the source-outputs
105 private static final String CMD_LIST_SOURCE_OUTPUTS = "list-source-outputs";
108 * command to load a module
110 private static final String CMD_LOAD_MODULE = "load-module";
113 * command to unload a module
115 private static final String CMD_UNLOAD_MODULE = "unload-module";
118 * name of the module-combine-sink
120 private static final String MODULE_COMBINE_SINK = "module-combine-sink";
122 public PulseaudioClient() throws IOException {
123 this("localhost", 4712);
126 public PulseaudioClient(String host, int port) throws IOException {
130 items = new ArrayList<>();
131 modules = new ArrayList<>();
137 public boolean isConnected() {
138 return client != null ? client.isConnected() : false;
142 * updates the item states and their relationships
144 public synchronized void update() {
146 modules = new ArrayList<Module>(Parser.parseModules(listModules()));
148 List<AbstractAudioDeviceConfig> newItems = new ArrayList<>(); // prepare new list before assigning it
150 if (Optional.ofNullable(TYPE_FILTERS.get(SINK_THING_TYPE.getId())).orElse(false)) {
151 logger.debug("reading sinks");
152 newItems.addAll(Parser.parseSinks(listSinks(), this));
154 if (Optional.ofNullable(TYPE_FILTERS.get(SOURCE_THING_TYPE.getId())).orElse(false)) {
155 logger.debug("reading sources");
156 newItems.addAll(Parser.parseSources(listSources(), this));
158 if (Optional.ofNullable(TYPE_FILTERS.get(SINK_INPUT_THING_TYPE.getId())).orElse(false)) {
159 logger.debug("reading sink-inputs");
160 newItems.addAll(Parser.parseSinkInputs(listSinkInputs(), this));
162 if (Optional.ofNullable(TYPE_FILTERS.get(SOURCE_OUTPUT_THING_TYPE.getId())).orElse(false)) {
163 logger.debug("reading source-outputs");
164 newItems.addAll(Parser.parseSourceOutputs(listSourceOutputs(), this));
166 logger.debug("Pulseaudio server {}: {} modules and {} items updated", host, modules.size(), newItems.size());
170 private String listModules() {
171 return this.sendRawRequest(CMD_LIST_MODULES);
174 private String listSinks() {
175 return this.sendRawRequest(CMD_LIST_SINKS);
178 private String listSources() {
179 return this.sendRawRequest(CMD_LIST_SOURCES);
182 private String listSinkInputs() {
183 return this.sendRawRequest(CMD_LIST_SINK_INPUTS);
186 private String listSourceOutputs() {
187 return this.sendRawRequest(CMD_LIST_SOURCE_OUTPUTS);
191 * retrieves a module by its id
194 * @return the corresponding {@link Module} to the given <code>id</code>
196 public Module getModule(int id) {
197 for (Module module : modules) {
198 if (module.getId() == id) {
206 * send the command directly to the pulseaudio server
207 * for a list of available commands please take a look at
208 * http://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/CLI
212 public void sendCommand(String command) {
213 sendRawCommand(command);
217 * retrieves a {@link Sink} by its name
219 * @return the corresponding {@link Sink} to the given <code>name</code>
221 public Sink getSink(String name) {
222 for (AbstractAudioDeviceConfig item : items) {
223 if (item.getPaName().equalsIgnoreCase(name) && item instanceof Sink) {
231 * retrieves a {@link Sink} by its id
233 * @return the corresponding {@link Sink} to the given <code>id</code>
235 public Sink getSink(int id) {
236 for (AbstractAudioDeviceConfig item : items) {
237 if (item.getId() == id && item instanceof Sink) {
245 * retrieves a {@link SinkInput} by its name
247 * @return the corresponding {@link SinkInput} to the given <code>name</code>
249 public SinkInput getSinkInput(String name) {
250 for (AbstractAudioDeviceConfig item : items) {
251 if (item.getPaName().equalsIgnoreCase(name) && item instanceof SinkInput) {
252 return (SinkInput) item;
259 * retrieves a {@link SinkInput} by its id
261 * @return the corresponding {@link SinkInput} to the given <code>id</code>
263 public SinkInput getSinkInput(int id) {
264 for (AbstractAudioDeviceConfig item : items) {
265 if (item.getId() == id && item instanceof SinkInput) {
266 return (SinkInput) item;
273 * retrieves a {@link Source} by its name
275 * @return the corresponding {@link Source} to the given <code>name</code>
277 public Source getSource(String name) {
278 for (AbstractAudioDeviceConfig item : items) {
279 if (item.getPaName().equalsIgnoreCase(name) && item instanceof Source) {
280 return (Source) item;
287 * retrieves a {@link Source} by its id
289 * @return the corresponding {@link Source} to the given <code>id</code>
291 public Source getSource(int id) {
292 for (AbstractAudioDeviceConfig item : items) {
293 if (item.getId() == id && item instanceof Source) {
294 return (Source) item;
301 * retrieves a {@link SourceOutput} by its name
303 * @return the corresponding {@link SourceOutput} to the given <code>name</code>
305 public SourceOutput getSourceOutput(String name) {
306 for (AbstractAudioDeviceConfig item : items) {
307 if (item.getPaName().equalsIgnoreCase(name) && item instanceof SourceOutput) {
308 return (SourceOutput) item;
315 * retrieves a {@link SourceOutput} by its id
317 * @return the corresponding {@link SourceOutput} to the given <code>id</code>
319 public SourceOutput getSourceOutput(int id) {
320 for (AbstractAudioDeviceConfig item : items) {
321 if (item.getId() == id && item instanceof SourceOutput) {
322 return (SourceOutput) item;
329 * retrieves a {@link AbstractAudioDeviceConfig} by its name
331 * @return the corresponding {@link AbstractAudioDeviceConfig} to the given <code>name</code>
333 public AbstractAudioDeviceConfig getGenericAudioItem(String name) {
334 for (AbstractAudioDeviceConfig item : items) {
335 if (item.getPaName().equalsIgnoreCase(name)) {
342 public List<AbstractAudioDeviceConfig> getItems() {
347 * changes the <code>mute</code> state of the corresponding {@link Sink}
349 * @param item the {@link Sink} to handle
350 * @param mute mutes the sink if true, unmutes if false
352 public void setMute(AbstractAudioDeviceConfig item, boolean mute) {
356 String itemCommandName = getItemCommandName(item);
357 if (itemCommandName == null) {
360 String muteString = mute ? "1" : "0";
361 sendRawCommand("set-" + itemCommandName + "-mute " + item.getId() + " " + muteString);
362 // update internal data
367 * change the volume of a {@link AbstractAudioDeviceConfig}
369 * @param item the {@link AbstractAudioDeviceConfig} to handle
370 * @param vol the new volume value the {@link AbstractAudioDeviceConfig} should be changed to (possible values from
373 public void setVolume(AbstractAudioDeviceConfig item, int vol) {
377 String itemCommandName = getItemCommandName(item);
378 if (itemCommandName == null) {
381 sendRawCommand("set-" + itemCommandName + "-volume " + item.getId() + " " + vol);
382 item.setVolume(Math.round(100f / 65536f * vol));
386 * Locate or load (if needed) the simple protocol tcp module for the given sink
387 * and returns the port.
388 * The module loading (if needed) will be tried several times, on a new random port each time.
390 * @param item the sink we are searching for
391 * @param simpleTcpPortPref the port to use if we have to load the module
392 * @return the port on which the module is listening
393 * @throws InterruptedException
395 public Optional<Integer> loadModuleSimpleProtocolTcpIfNeeded(AbstractAudioDeviceConfig item,
396 Integer simpleTcpPortPref) throws InterruptedException {
398 int simpleTcpPortToTry = simpleTcpPortPref;
400 Optional<Integer> simplePort = findSimpleProtocolTcpModule(item);
402 if (simplePort.isPresent()) {
405 sendRawCommand("load-module module-simple-protocol-tcp sink=" + item.getPaName() + " port="
406 + simpleTcpPortToTry);
407 simpleTcpPortToTry = new Random().nextInt(64512) + 1024; // a random port above 1024
411 } while (currentTry < 3);
413 logger.warn("The pulseaudio binding tried 3 times to load the module-simple-protocol-tcp"
414 + " on random port on the pulseaudio server and give up trying");
415 return Optional.empty();
419 * Find a simple protocol module corresponding to the given sink in argument
420 * and returns the port it listens to
425 private Optional<Integer> findSimpleProtocolTcpModule(AbstractAudioDeviceConfig item) {
428 List<Module> modulesCopy = new ArrayList<Module>(modules);
429 return modulesCopy.stream() // iteration on modules
430 .filter(module -> MODULE_SIMPLE_PROTOCOL_TCP_NAME.equals(module.getPaName())) // filter on module name
431 .filter(module -> extractArgumentFromLine("sink", module.getArgument()) // extract sink in argument
432 .map(sinkName -> sinkName.equals(item.getPaName())).orElse(false)) // filter on sink name
433 .findAny() // get a corresponding module
434 .map(module -> extractArgumentFromLine("port", module.getArgument())
435 .orElse(Integer.toString(MODULE_SIMPLE_PROTOCOL_TCP_DEFAULT_PORT))) // get port
436 .map(portS -> Integer.parseInt(portS));
439 private @NonNull Optional<@NonNull String> extractArgumentFromLine(String argumentWanted, String argumentLine) {
440 String argument = null;
441 int startPortIndex = argumentLine.indexOf(argumentWanted + "=");
442 if (startPortIndex != -1) {
443 startPortIndex = startPortIndex + argumentWanted.length() + 1;
444 int endPortIndex = argumentLine.indexOf(" ", startPortIndex);
445 if (endPortIndex == -1) {
446 endPortIndex = argumentLine.length();
448 argument = argumentLine.substring(startPortIndex, endPortIndex);
450 return Optional.ofNullable(argument);
454 * returns the item names that can be used in commands
459 private String getItemCommandName(AbstractAudioDeviceConfig item) {
460 if (item instanceof Sink) {
462 } else if (item instanceof Source) {
464 } else if (item instanceof SinkInput) {
465 return ITEM_SINK_INPUT;
466 } else if (item instanceof SourceOutput) {
467 return ITEM_SOURCE_OUTPUT;
473 * change the volume of a {@link AbstractAudioDeviceConfig}
475 * @param item the {@link AbstractAudioDeviceConfig} to handle
476 * @param vol the new volume percent value the {@link AbstractAudioDeviceConfig} should be changed to (possible
477 * values from 0 - 100)
479 public void setVolumePercent(AbstractAudioDeviceConfig item, int vol) {
480 int volumeToSet = vol;
484 if (volumeToSet <= 100) {
485 volumeToSet = toAbsoluteVolume(volumeToSet);
487 setVolume(item, volumeToSet);
491 * transform a percent volume to a value that can be send to the pulseaudio server (0-65536)
496 private int toAbsoluteVolume(int percent) {
497 return (int) Math.round(65536f / 100f * Double.valueOf(percent));
501 * changes the combined sinks slaves to the given <code>sinks</code>
503 * @param combinedSink the combined sink which slaves should be changed
504 * @param sinks the list of new slaves
506 public void setCombinedSinkSlaves(Sink combinedSink, List<Sink> sinks) {
507 if (combinedSink == null || !combinedSink.isCombinedSink()) {
510 List<String> slaves = new ArrayList<>();
511 for (Sink sink : sinks) {
512 slaves.add(sink.getPaName());
514 // 1. delete old combined-sink
515 sendRawCommand(CMD_UNLOAD_MODULE + " " + combinedSink.getModule().getId());
516 // 2. add new combined-sink with same name and all slaves
517 sendRawCommand(CMD_LOAD_MODULE + " " + MODULE_COMBINE_SINK + " sink_name=" + combinedSink.getPaName()
518 + " slaves=" + String.join(",", slaves));
519 // 3. update internal data structure because the combined sink has a new number + other slaves
524 * sets the sink a sink-input should be routed to
526 * @param sinkInput the sink-input to be rerouted
527 * @param sink the new sink the sink-input should be routed to
529 public void moveSinkInput(SinkInput sinkInput, Sink sink) {
530 if (sinkInput == null || sink == null) {
533 sendRawCommand("move-sink-input " + sinkInput.getId() + " " + sink.getId());
534 sinkInput.setSink(sink);
538 * sets the sink a source-output should be routed to
540 * @param sourceOutput the source-output to be rerouted
541 * @param source the new source the source-output should be routed to
543 public void moveSourceOutput(SourceOutput sourceOutput, Source source) {
544 if (sourceOutput == null || source == null) {
547 sendRawCommand("move-sink-input " + sourceOutput.getId() + " " + source.getId());
548 sourceOutput.setSource(source);
554 * @param source the source which state should be changed
555 * @param suspend suspend it or not
557 public void suspendSource(Source source, boolean suspend) {
558 if (source == null) {
562 sendRawCommand("suspend-source " + source.getId() + " 1");
563 source.setState(State.SUSPENDED);
565 sendRawCommand("suspend-source " + source.getId() + " 0");
566 // unsuspending the source could result in different states (RUNNING,IDLE,...)
567 // update to get the new state
575 * @param sink the sink which state should be changed
576 * @param suspend suspend it or not
578 public void suspendSink(Sink sink, boolean suspend) {
583 sendRawCommand("suspend-sink " + sink.getId() + " 1");
584 sink.setState(State.SUSPENDED);
586 sendRawCommand("suspend-sink " + sink.getId() + " 0");
587 // unsuspending the sink could result in different states (RUNNING,IDLE,...)
588 // update to get the new state
594 * changes the combined sinks slaves to the given <code>sinks</code>
596 * @param combinedSinkName the combined sink which slaves should be changed
597 * @param sinks the list of new slaves
599 public void setCombinedSinkSlaves(String combinedSinkName, List<Sink> sinks) {
600 if (getSink(combinedSinkName) != null) {
603 List<String> slaves = new ArrayList<>();
604 for (Sink sink : sinks) {
605 slaves.add(sink.getPaName());
607 // add new combined-sink with same name and all slaves
608 sendRawCommand(CMD_LOAD_MODULE + " " + MODULE_COMBINE_SINK + " sink_name=" + combinedSinkName + " slaves="
609 + String.join(",", slaves));
610 // update internal data structure because the combined sink is new
614 private void sendRawCommand(String command) {
616 if (client != null) {
618 PrintStream out = new PrintStream(client.getOutputStream(), true);
619 logger.trace("sending command {} to pa-server {}", command, host);
620 out.print(command + "\r\n");
623 } catch (IOException e) {
624 logger.error("{}", e.getLocalizedMessage(), e);
629 private String sendRawRequest(String command) {
630 logger.trace("_sendRawRequest({})", command);
633 if (client != null) {
635 PrintStream out = new PrintStream(client.getOutputStream(), true);
636 out.print(command + "\r\n");
638 InputStream instr = client.getInputStream();
641 byte[] buff = new byte[1024];
645 retRead = instr.read(buff);
648 String line = new String(buff, 0, retRead);
649 // System.out.println("'"+line+"'");
650 if (line.endsWith(">>> ") && lc > 1) {
651 result += line.substring(0, line.length() - 4);
654 result += line.trim();
656 } while (retRead > 0);
657 } catch (SocketTimeoutException e) {
658 // Timeout -> as newer PA versions (>=5.0) do not send the >>> we have no chance
659 // to detect the end of the answer, except by this timeout
660 } catch (SocketException e) {
661 logger.warn("Socket exception while sending pulseaudio command: {}", e.getMessage());
662 } catch (IOException e) {
663 logger.error("Exception while reading socket: {}", e.getMessage());
669 } catch (IOException e) {
670 logger.error("{}", e.getLocalizedMessage(), e);
676 private void checkConnection() {
677 if (client == null || client.isClosed() || !client.isConnected()) {
680 } catch (IOException e) {
681 logger.error("{}", e.getLocalizedMessage(), e);
687 * Connects to the pulseaudio server (timeout 500ms)
689 private void connect() throws IOException {
691 client = new Socket(host, port);
692 client.setSoTimeout(500);
693 } catch (UnknownHostException e) {
694 logger.error("unknown socket host {}", host);
695 } catch (NoRouteToHostException e) {
696 logger.error("no route to host {}", host);
697 } catch (SocketException e) {
698 logger.error("cannot connect to host {} : {}", host, e.getMessage());
703 * Disconnects from the pulseaudio server
705 public void disconnect() {
706 if (client != null) {
709 } catch (IOException e) {
710 logger.error("{}", e.getLocalizedMessage(), e);