2 * Copyright (c) 2010-2022 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.math.BigDecimal;
21 import java.net.NoRouteToHostException;
22 import java.net.Socket;
23 import java.net.SocketException;
24 import java.net.SocketTimeoutException;
25 import java.net.UnknownHostException;
26 import java.util.ArrayList;
27 import java.util.List;
28 import java.util.Optional;
29 import java.util.Random;
31 import org.eclipse.jdt.annotation.NonNull;
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.openhab.binding.pulseaudio.internal.cli.Parser;
35 import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig;
36 import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig.State;
37 import org.openhab.binding.pulseaudio.internal.items.Module;
38 import org.openhab.binding.pulseaudio.internal.items.Sink;
39 import org.openhab.binding.pulseaudio.internal.items.SinkInput;
40 import org.openhab.binding.pulseaudio.internal.items.Source;
41 import org.openhab.binding.pulseaudio.internal.items.SourceOutput;
42 import org.slf4j.Logger;
43 import org.slf4j.LoggerFactory;
46 * The client connects to a pulseaudio server via TCP. It reads the current state of the
47 * pulseaudio server (available sinks, sources,...) and can send commands to the server.
48 * The syntax of the commands is the same as for the pactl command line tool provided by pulseaudio.
50 * On the pulseaudio server the module-cli-protocol-tcp has to be loaded.
52 * @author Tobias Bräutigam - Initial contribution
53 * @author Miguel Álvarez - changes for loading audio source module and nullability annotations
56 public class PulseaudioClient {
58 private final Logger logger = LoggerFactory.getLogger(PulseaudioClient.class);
62 private @Nullable Socket client;
64 private List<AbstractAudioDeviceConfig> items;
65 private List<Module> modules;
68 * Corresponding to the global binding configuration
70 private PulseAudioBindingConfiguration configuration;
73 * corresponding name to execute actions on sink items
75 private static final String ITEM_SINK = "sink";
78 * corresponding name to execute actions on source items
80 private static final String ITEM_SOURCE = "source";
83 * corresponding name to execute actions on sink-input items
85 private static final String ITEM_SINK_INPUT = "sink-input";
88 * corresponding name to execute actions on source-output items
90 private static final String ITEM_SOURCE_OUTPUT = "source-output";
93 * command to list the loaded modules
95 private static final String CMD_LIST_MODULES = "list-modules";
98 * command to list the sinks
100 private static final String CMD_LIST_SINKS = "list-sinks";
103 * command to list the sources
105 private static final String CMD_LIST_SOURCES = "list-sources";
108 * command to list the sink-inputs
110 private static final String CMD_LIST_SINK_INPUTS = "list-sink-inputs";
113 * command to list the source-outputs
115 private static final String CMD_LIST_SOURCE_OUTPUTS = "list-source-outputs";
118 * command to load a module
120 private static final String CMD_LOAD_MODULE = "load-module";
123 * command to unload a module
125 private static final String CMD_UNLOAD_MODULE = "unload-module";
128 * name of the module-combine-sink
130 private static final String MODULE_COMBINE_SINK = "module-combine-sink";
132 public PulseaudioClient(String host, int port, PulseAudioBindingConfiguration configuration) throws IOException {
135 this.configuration = configuration;
137 items = new ArrayList<>();
138 modules = new ArrayList<>();
144 public boolean isConnected() {
145 return client != null ? client.isConnected() : false;
149 * updates the item states and their relationships
151 public synchronized void update() {
153 modules = new ArrayList<Module>(Parser.parseModules(listModules()));
155 List<AbstractAudioDeviceConfig> newItems = new ArrayList<>(); // prepare new list before assigning it
157 if (configuration.sink) {
158 logger.debug("reading sinks");
159 newItems.addAll(Parser.parseSinks(listSinks(), this));
161 if (configuration.source) {
162 logger.debug("reading sources");
163 newItems.addAll(Parser.parseSources(listSources(), this));
165 if (configuration.sinkInput) {
166 logger.debug("reading sink-inputs");
167 newItems.addAll(Parser.parseSinkInputs(listSinkInputs(), this));
169 if (configuration.sourceOutput) {
170 logger.debug("reading source-outputs");
171 newItems.addAll(Parser.parseSourceOutputs(listSourceOutputs(), this));
173 logger.debug("Pulseaudio server {}: {} modules and {} items updated", host, modules.size(), newItems.size());
177 private String listModules() {
178 return this.sendRawRequest(CMD_LIST_MODULES);
181 private String listSinks() {
182 return this.sendRawRequest(CMD_LIST_SINKS);
185 private String listSources() {
186 return this.sendRawRequest(CMD_LIST_SOURCES);
189 private String listSinkInputs() {
190 return this.sendRawRequest(CMD_LIST_SINK_INPUTS);
193 private String listSourceOutputs() {
194 return this.sendRawRequest(CMD_LIST_SOURCE_OUTPUTS);
198 * retrieves a module by its id
201 * @return the corresponding {@link Module} to the given <code>id</code>
203 public @Nullable Module getModule(int id) {
204 for (Module module : modules) {
205 if (module.getId() == id) {
213 * send the command directly to the pulseaudio server
214 * for a list of available commands please take a look at
215 * http://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/CLI
219 public void sendCommand(String command) {
220 sendRawCommand(command);
224 * retrieves a {@link Sink} by its name
226 * @return the corresponding {@link Sink} to the given <code>name</code>
228 public @Nullable Sink getSink(String name) {
229 for (AbstractAudioDeviceConfig item : items) {
230 if (item.getPaName().equalsIgnoreCase(name) && item instanceof Sink) {
238 * retrieves a {@link Sink} by its id
240 * @return the corresponding {@link Sink} to the given <code>id</code>
242 public @Nullable Sink getSink(int id) {
243 for (AbstractAudioDeviceConfig item : items) {
244 if (item.getId() == id && item instanceof Sink) {
252 * retrieves a {@link SinkInput} by its name
254 * @return the corresponding {@link SinkInput} to the given <code>name</code>
256 public @Nullable SinkInput getSinkInput(String name) {
257 for (AbstractAudioDeviceConfig item : items) {
258 if (item.getPaName().equalsIgnoreCase(name) && item instanceof SinkInput) {
259 return (SinkInput) item;
266 * retrieves a {@link SinkInput} by its id
268 * @return the corresponding {@link SinkInput} to the given <code>id</code>
270 public @Nullable SinkInput getSinkInput(int id) {
271 for (AbstractAudioDeviceConfig item : items) {
272 if (item.getId() == id && item instanceof SinkInput) {
273 return (SinkInput) item;
280 * retrieves a {@link Source} by its name
282 * @return the corresponding {@link Source} to the given <code>name</code>
284 public @Nullable Source getSource(String name) {
285 for (AbstractAudioDeviceConfig item : items) {
286 if (item.getPaName().equalsIgnoreCase(name) && item instanceof Source) {
287 return (Source) item;
294 * retrieves a {@link Source} by its id
296 * @return the corresponding {@link Source} to the given <code>id</code>
298 public @Nullable Source getSource(int id) {
299 for (AbstractAudioDeviceConfig item : items) {
300 if (item.getId() == id && item instanceof Source) {
301 return (Source) item;
308 * retrieves a {@link SourceOutput} by its name
310 * @return the corresponding {@link SourceOutput} to the given <code>name</code>
312 public @Nullable SourceOutput getSourceOutput(String name) {
313 for (AbstractAudioDeviceConfig item : items) {
314 if (item.getPaName().equalsIgnoreCase(name) && item instanceof SourceOutput) {
315 return (SourceOutput) item;
322 * retrieves a {@link SourceOutput} by its id
324 * @return the corresponding {@link SourceOutput} to the given <code>id</code>
326 public @Nullable SourceOutput getSourceOutput(int id) {
327 for (AbstractAudioDeviceConfig item : items) {
328 if (item.getId() == id && item instanceof SourceOutput) {
329 return (SourceOutput) item;
336 * retrieves a {@link AbstractAudioDeviceConfig} by its name
338 * @return the corresponding {@link AbstractAudioDeviceConfig} to the given <code>name</code>
340 public @Nullable AbstractAudioDeviceConfig getGenericAudioItem(String name) {
341 for (AbstractAudioDeviceConfig item : items) {
342 if (item.getPaName().equalsIgnoreCase(name)) {
349 public List<AbstractAudioDeviceConfig> getItems() {
354 * changes the <code>mute</code> state of the corresponding {@link Sink}
356 * @param item the {@link Sink} to handle
357 * @param mute mutes the sink if true, unmutes if false
359 public void setMute(@Nullable AbstractAudioDeviceConfig item, boolean mute) {
363 String itemCommandName = getItemCommandName(item);
364 if (itemCommandName == null) {
367 String muteString = mute ? "1" : "0";
368 sendRawCommand("set-" + itemCommandName + "-mute " + item.getId() + " " + muteString);
369 // update internal data
374 * change the volume of a {@link AbstractAudioDeviceConfig}
376 * @param item the {@link AbstractAudioDeviceConfig} to handle
377 * @param vol the new volume value the {@link AbstractAudioDeviceConfig} should be changed to (possible values from
380 public void setVolume(AbstractAudioDeviceConfig item, int vol) {
384 String itemCommandName = getItemCommandName(item);
385 if (itemCommandName == null) {
388 sendRawCommand("set-" + itemCommandName + "-volume " + item.getId() + " " + vol);
389 item.setVolume(Math.round(100f / 65536f * vol));
393 * Locate or load (if needed) the simple protocol tcp module for the given sink
394 * and returns the port.
395 * The module loading (if needed) will be tried several times, on a new random port each time.
397 * @param item the sink we are searching for
398 * @param simpleTcpPortPref the port to use if we have to load the module
399 * @return the port on which the module is listening
400 * @throws InterruptedException
402 public Optional<Integer> loadModuleSimpleProtocolTcpIfNeeded(AbstractAudioDeviceConfig item,
403 Integer simpleTcpPortPref, @Nullable String format, @Nullable BigDecimal rate,
404 @Nullable BigDecimal channels) throws InterruptedException {
406 int simpleTcpPortToTry = simpleTcpPortPref;
407 String itemType = getItemCommandName(item);
409 Optional<Integer> simplePort = findSimpleProtocolTcpModule(item, format, rate, channels);
411 if (simplePort.isPresent()) {
414 String moduleOptions = itemType + "=" + item.getPaName() + " port=" + simpleTcpPortToTry;
415 if (item instanceof Source && format != null && rate != null && channels != null) {
416 moduleOptions = moduleOptions + String.format(" record=true format=%s rate=%d channels=%d", format,
417 rate.longValue(), channels.intValue());
419 sendRawCommand("load-module module-simple-protocol-tcp " + moduleOptions);
420 simpleTcpPortToTry = new Random().nextInt(64512) + 1024; // a random port above 1024
425 } while (currentTry < 3);
427 logger.warn("The pulseaudio binding tried 3 times to load the module-simple-protocol-tcp"
428 + " on random port on the pulseaudio server and give up trying");
429 return Optional.empty();
433 * Find a simple protocol module corresponding to the given sink in argument
434 * and returns the port it listens to
439 private Optional<Integer> findSimpleProtocolTcpModule(AbstractAudioDeviceConfig item, @Nullable String format,
440 @Nullable BigDecimal rate, @Nullable BigDecimal channels) {
441 String itemType = getItemCommandName(item);
442 if (itemType == null) {
443 return Optional.empty();
445 List<Module> modulesCopy = new ArrayList<Module>(modules);
446 var isSource = item instanceof Source;
447 return modulesCopy.stream() // iteration on modules
448 .filter(module -> MODULE_SIMPLE_PROTOCOL_TCP_NAME.equals(module.getPaName())) // filter on module name
450 boolean nameMatch = extractArgumentFromLine(itemType, module.getArgument()) // extract sick|source
451 .map(name -> name.equals(item.getPaName())).orElse(false);
452 if (isSource && nameMatch) {
453 boolean recordStream = extractArgumentFromLine("record", module.getArgument())
454 .map("true"::equals).orElse(false);
458 if (format != null) {
459 boolean rateMatch = extractArgumentFromLine("format", module.getArgument())
460 .map(format::equals).orElse(false);
466 boolean rateMatch = extractArgumentFromLine("rate", module.getArgument())
467 .map(value -> Long.parseLong(value) == rate.longValue()).orElse(false);
472 if (channels != null) {
473 boolean channelsMatch = extractArgumentFromLine("channels", module.getArgument())
474 .map(value -> Integer.parseInt(value) == channels.intValue()).orElse(false);
475 if (!channelsMatch) {
481 }) // filter on sink name
482 .findAny() // get a corresponding module
483 .map(module -> extractArgumentFromLine("port", module.getArgument())
484 .orElse(Integer.toString(MODULE_SIMPLE_PROTOCOL_TCP_DEFAULT_PORT))) // get port
485 .map(portS -> Integer.parseInt(portS));
488 private @NonNull Optional<@NonNull String> extractArgumentFromLine(String argumentWanted, String argumentLine) {
489 String argument = null;
490 int startPortIndex = argumentLine.indexOf(argumentWanted + "=");
491 if (startPortIndex != -1) {
492 startPortIndex = startPortIndex + argumentWanted.length() + 1;
493 int endPortIndex = argumentLine.indexOf(" ", startPortIndex);
494 if (endPortIndex == -1) {
495 endPortIndex = argumentLine.length();
497 argument = argumentLine.substring(startPortIndex, endPortIndex);
499 return Optional.ofNullable(argument);
503 * returns the item names that can be used in commands
508 private @Nullable String getItemCommandName(AbstractAudioDeviceConfig item) {
509 if (item instanceof Sink) {
511 } else if (item instanceof Source) {
513 } else if (item instanceof SinkInput) {
514 return ITEM_SINK_INPUT;
515 } else if (item instanceof SourceOutput) {
516 return ITEM_SOURCE_OUTPUT;
522 * change the volume of a {@link AbstractAudioDeviceConfig}
524 * @param item the {@link AbstractAudioDeviceConfig} to handle
525 * @param vol the new volume percent value the {@link AbstractAudioDeviceConfig} should be changed to (possible
526 * values from 0 - 100)
528 public void setVolumePercent(@Nullable AbstractAudioDeviceConfig item, int vol) {
529 int volumeToSet = vol;
533 if (volumeToSet <= 100) {
534 volumeToSet = toAbsoluteVolume(volumeToSet);
536 setVolume(item, volumeToSet);
540 * transform a percent volume to a value that can be send to the pulseaudio server (0-65536)
545 private int toAbsoluteVolume(int percent) {
546 return (int) Math.round(65536f / 100f * Double.valueOf(percent));
550 * changes the combined sinks slaves to the given <code>sinks</code>
552 * @param combinedSink the combined sink which slaves should be changed
553 * @param sinks the list of new slaves
555 public void setCombinedSinkSlaves(@Nullable Sink combinedSink, List<Sink> sinks) {
556 if (combinedSink == null || !combinedSink.isCombinedSink()) {
559 List<String> slaves = new ArrayList<>();
560 for (Sink sink : sinks) {
561 slaves.add(sink.getPaName());
563 // 1. delete old combined-sink
564 sendRawCommand(CMD_UNLOAD_MODULE + " " + combinedSink.getModule().getId());
565 // 2. add new combined-sink with same name and all slaves
566 sendRawCommand(CMD_LOAD_MODULE + " " + MODULE_COMBINE_SINK + " sink_name=" + combinedSink.getPaName()
567 + " slaves=" + String.join(",", slaves));
568 // 3. update internal data structure because the combined sink has a new number + other slaves
573 * sets the sink a sink-input should be routed to
575 * @param sinkInput the sink-input to be rerouted
576 * @param sink the new sink the sink-input should be routed to
578 public void moveSinkInput(@Nullable SinkInput sinkInput, @Nullable Sink sink) {
579 if (sinkInput == null || sink == null) {
582 sendRawCommand("move-sink-input " + sinkInput.getId() + " " + sink.getId());
583 sinkInput.setSink(sink);
587 * sets the sink a source-output should be routed to
589 * @param sourceOutput the source-output to be rerouted
590 * @param source the new source the source-output should be routed to
592 public void moveSourceOutput(@Nullable SourceOutput sourceOutput, @Nullable Source source) {
593 if (sourceOutput == null || source == null) {
596 sendRawCommand("move-sink-input " + sourceOutput.getId() + " " + source.getId());
597 sourceOutput.setSource(source);
603 * @param source the source which state should be changed
604 * @param suspend suspend it or not
606 public void suspendSource(@Nullable Source source, boolean suspend) {
607 if (source == null) {
611 sendRawCommand("suspend-source " + source.getId() + " 1");
612 source.setState(State.SUSPENDED);
614 sendRawCommand("suspend-source " + source.getId() + " 0");
615 // unsuspending the source could result in different states (RUNNING,IDLE,...)
616 // update to get the new state
624 * @param sink the sink which state should be changed
625 * @param suspend suspend it or not
627 public void suspendSink(@Nullable Sink sink, boolean suspend) {
632 sendRawCommand("suspend-sink " + sink.getId() + " 1");
633 sink.setState(State.SUSPENDED);
635 sendRawCommand("suspend-sink " + sink.getId() + " 0");
636 // unsuspending the sink could result in different states (RUNNING,IDLE,...)
637 // update to get the new state
643 * changes the combined sinks slaves to the given <code>sinks</code>
645 * @param combinedSinkName the combined sink which slaves should be changed
646 * @param sinks the list of new slaves
648 public void setCombinedSinkSlaves(String combinedSinkName, List<Sink> sinks) {
649 if (getSink(combinedSinkName) != null) {
652 List<String> slaves = new ArrayList<>();
653 for (Sink sink : sinks) {
654 slaves.add(sink.getPaName());
656 // add new combined-sink with same name and all slaves
657 sendRawCommand(CMD_LOAD_MODULE + " " + MODULE_COMBINE_SINK + " sink_name=" + combinedSinkName + " slaves="
658 + String.join(",", slaves));
659 // update internal data structure because the combined sink is new
663 private synchronized void sendRawCommand(String command) {
665 if (client != null && client.isConnected()) {
667 PrintStream out = new PrintStream(client.getOutputStream(), true);
668 logger.trace("sending command {} to pa-server {}", command, host);
669 out.print(command + "\r\n");
672 } catch (IOException e) {
673 logger.error("{}", e.getLocalizedMessage(), e);
678 private String sendRawRequest(String command) {
679 logger.trace("_sendRawRequest({})", command);
682 if (client != null && client.isConnected()) {
684 PrintStream out = new PrintStream(client.getOutputStream(), true);
685 out.print(command + "\r\n");
687 InputStream instr = client.getInputStream();
690 byte[] buff = new byte[1024];
694 retRead = instr.read(buff);
697 String line = new String(buff, 0, retRead);
698 // System.out.println("'"+line+"'");
699 if (line.endsWith(">>> ") && lc > 1) {
700 result += line.substring(0, line.length() - 4);
703 result += line.trim();
705 } while (retRead > 0);
706 } catch (SocketTimeoutException e) {
707 // Timeout -> as newer PA versions (>=5.0) do not send the >>> we have no chance
708 // to detect the end of the answer, except by this timeout
709 } catch (SocketException e) {
710 logger.warn("Socket exception while sending pulseaudio command: {}", e.getMessage());
711 } catch (IOException e) {
712 logger.error("Exception while reading socket: {}", e.getMessage());
718 } catch (IOException e) {
719 logger.error("{}", e.getLocalizedMessage(), e);
725 private void checkConnection() {
726 if (client == null || client.isClosed() || !client.isConnected()) {
729 } catch (IOException e) {
730 logger.error("{}", e.getLocalizedMessage(), e);
736 * Connects to the pulseaudio server (timeout 500ms)
738 private void connect() throws IOException {
740 client = new Socket(host, port);
741 client.setSoTimeout(500);
742 } catch (UnknownHostException e) {
743 logger.error("unknown socket host {}", host);
744 } catch (NoRouteToHostException e) {
745 logger.error("no route to host {}", host);
746 } catch (SocketException e) {
747 logger.error("cannot connect to host {} : {}", host, e.getMessage());
752 * Disconnects from the pulseaudio server
754 public void disconnect() {
755 if (client != null) {
758 } catch (IOException e) {
759 logger.error("{}", e.getLocalizedMessage(), e);