2 * Copyright (c) 2010-2024 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.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.Comparator;
27 import java.util.List;
28 import java.util.Optional;
29 import java.util.Random;
30 import java.util.stream.Collectors;
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.handler.DeviceIdentifier;
36 import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig;
37 import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig.State;
38 import org.openhab.binding.pulseaudio.internal.items.Module;
39 import org.openhab.binding.pulseaudio.internal.items.Sink;
40 import org.openhab.binding.pulseaudio.internal.items.SinkInput;
41 import org.openhab.binding.pulseaudio.internal.items.Source;
42 import org.openhab.binding.pulseaudio.internal.items.SourceOutput;
43 import org.slf4j.Logger;
44 import org.slf4j.LoggerFactory;
47 * The client connects to a pulseaudio server via TCP. It reads the current state of the
48 * pulseaudio server (available sinks, sources,...) and can send commands to the server.
49 * The syntax of the commands is the same as for the pactl command line tool provided by pulseaudio.
51 * On the pulseaudio server the module-cli-protocol-tcp has to be loaded.
53 * @author Tobias Bräutigam - Initial contribution
54 * @author Miguel Álvarez - changes for loading audio source module and nullability annotations
57 public class PulseaudioClient {
59 private final Logger logger = LoggerFactory.getLogger(PulseaudioClient.class);
63 private @Nullable Socket client;
65 private List<AbstractAudioDeviceConfig> items;
66 private List<Module> modules;
69 * Corresponding to the global binding configuration
71 private PulseAudioBindingConfiguration configuration;
74 * corresponding name to execute actions on sink items
76 private static final String ITEM_SINK = "sink";
79 * corresponding name to execute actions on source items
81 private static final String ITEM_SOURCE = "source";
84 * corresponding name to execute actions on sink-input items
86 private static final String ITEM_SINK_INPUT = "sink-input";
89 * corresponding name to execute actions on source-output items
91 private static final String ITEM_SOURCE_OUTPUT = "source-output";
94 * command to list the loaded modules
96 private static final String CMD_LIST_MODULES = "list-modules";
99 * command to list the sinks
101 private static final String CMD_LIST_SINKS = "list-sinks";
104 * command to list the sources
106 private static final String CMD_LIST_SOURCES = "list-sources";
109 * command to list the sink-inputs
111 private static final String CMD_LIST_SINK_INPUTS = "list-sink-inputs";
114 * command to list the source-outputs
116 private static final String CMD_LIST_SOURCE_OUTPUTS = "list-source-outputs";
119 * command to load a module
121 private static final String CMD_LOAD_MODULE = "load-module";
124 * command to unload a module
126 private static final String CMD_UNLOAD_MODULE = "unload-module";
129 * name of the module-combine-sink
131 private static final String MODULE_COMBINE_SINK = "module-combine-sink";
133 public PulseaudioClient(String host, int port, PulseAudioBindingConfiguration configuration) {
136 this.configuration = configuration;
138 items = new ArrayList<>();
139 modules = new ArrayList<>();
142 public boolean isConnected() {
143 Socket clientSocket = client;
144 return clientSocket != null ? clientSocket.isConnected() : false;
148 * updates the item states and their relationships
150 public synchronized void update() {
152 modules = new ArrayList<Module>(Parser.parseModules(listModules()));
154 List<AbstractAudioDeviceConfig> newItems = new ArrayList<>(); // prepare new list before assigning it
155 if (configuration.sink) {
156 logger.debug("reading sinks");
157 newItems.addAll(Parser.parseSinks(listSinks(), this));
159 if (configuration.source) {
160 logger.debug("reading sources");
161 newItems.addAll(Parser.parseSources(listSources(), this));
163 if (configuration.sinkInput) {
164 logger.debug("reading sink-inputs");
165 newItems.addAll(Parser.parseSinkInputs(listSinkInputs(), this));
167 if (configuration.sourceOutput) {
168 logger.debug("reading source-outputs");
169 newItems.addAll(Parser.parseSourceOutputs(listSourceOutputs(), this));
171 logger.debug("Pulseaudio server {}: {} modules and {} items updated", host, modules.size(), newItems.size());
175 private String listModules() {
176 return this.sendRawRequest(CMD_LIST_MODULES);
179 private String listSinks() {
180 return this.sendRawRequest(CMD_LIST_SINKS);
183 private String listSources() {
184 return this.sendRawRequest(CMD_LIST_SOURCES);
187 private String listSinkInputs() {
188 return this.sendRawRequest(CMD_LIST_SINK_INPUTS);
191 private String listSourceOutputs() {
192 return this.sendRawRequest(CMD_LIST_SOURCE_OUTPUTS);
196 * retrieves a module by its id
199 * @return the corresponding {@link Module} to the given <code>id</code>
201 public @Nullable Module getModule(int id) {
202 for (Module module : modules) {
203 if (module.getId() == id) {
211 * send the command directly to the pulseaudio server
212 * for a list of available commands please take a look at
213 * http://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/CLI
217 public void sendCommand(String command) {
218 sendRawCommand(command);
222 * retrieves a {@link Sink} by its name
224 * @return the corresponding {@link Sink} to the given <code>name</code>
226 public @Nullable Sink getSink(String name) {
227 for (AbstractAudioDeviceConfig item : items) {
228 if (item.getPaName().equalsIgnoreCase(name) && item instanceof Sink sink) {
236 * retrieves a {@link Sink} by its id
238 * @return the corresponding {@link Sink} to the given <code>id</code>
240 public @Nullable Sink getSink(int id) {
241 for (AbstractAudioDeviceConfig item : items) {
242 if (item.getId() == id && item instanceof Sink sink) {
250 * retrieves a {@link Source} by its id
252 * @return the corresponding {@link Source} to the given <code>id</code>
254 public @Nullable Source getSource(int id) {
255 for (AbstractAudioDeviceConfig item : items) {
256 if (item.getId() == id && item instanceof Source source) {
264 * retrieves an {@link AbstractAudioDeviceConfig} by its identifier
265 * If several devices correspond to the deviceIdentifier, returns the first one (aphabetical order)
267 * @param deviceIdentifier The device identifier to match against
268 * @return the corresponding {@link AbstractAudioDeviceConfig} to the given <code>name</code>
270 public @Nullable AbstractAudioDeviceConfig getGenericAudioItem(DeviceIdentifier deviceIdentifier) {
271 List<AbstractAudioDeviceConfig> matchingDevices = items.stream()
272 .filter(device -> device.matches(deviceIdentifier))
273 .sorted(Comparator.comparing(AbstractAudioDeviceConfig::getPaName)).collect(Collectors.toList());
274 if (matchingDevices.size() == 1) {
275 return matchingDevices.get(0);
276 } else if (matchingDevices.size() > 1) {
278 "Cannot select exactly one audio device, so choosing the first. To choose without ambiguity between the {} devices matching the identifier {}, you can maybe use a more restrictive 'additionalFilter' parameter",
279 matchingDevices.size(), deviceIdentifier.getNameOrDescription());
280 return matchingDevices.get(0);
286 * Get all items previously parsed from the pulseaudio server.
288 * @return All items parsed from the pulseaudio server
290 public List<AbstractAudioDeviceConfig> getItems() {
295 * changes the <code>mute</code> state of the corresponding {@link Sink}
297 * @param item the {@link Sink} to handle
298 * @param mute mutes the sink if true, unmutes if false
300 public void setMute(@Nullable AbstractAudioDeviceConfig item, boolean mute) {
304 String itemCommandName = getItemCommandName(item);
305 if (itemCommandName == null) {
308 String muteString = mute ? "1" : "0";
309 sendRawCommand("set-" + itemCommandName + "-mute " + item.getId() + " " + muteString);
310 // update internal data
315 * change the volume of an {@link AbstractAudioDeviceConfig}
317 * @param item the {@link AbstractAudioDeviceConfig} to handle
318 * @param vol the new volume value the {@link AbstractAudioDeviceConfig} should be changed to (possible values from
321 public void setVolume(AbstractAudioDeviceConfig item, int vol) {
322 String itemCommandName = getItemCommandName(item);
323 if (itemCommandName == null) {
326 sendRawCommand("set-" + itemCommandName + "-volume " + item.getId() + " " + vol);
327 item.setVolume(Math.round(100f / 65536f * vol));
331 * Locate or load (if needed) the simple protocol tcp module for the given sink
332 * and returns the port.
333 * The module loading (if needed) will be tried several times, on a new random port each time.
335 * @param item the sink we are searching for
336 * @param simpleTcpPortPref the port to use if we have to load the module
337 * @return the port on which the module is listening
338 * @throws InterruptedException
340 public Optional<Integer> loadModuleSimpleProtocolTcpIfNeeded(AbstractAudioDeviceConfig item,
341 Integer simpleTcpPortPref, @Nullable String format, @Nullable BigDecimal rate,
342 @Nullable BigDecimal channels) throws InterruptedException {
344 int simpleTcpPortToTry = simpleTcpPortPref;
345 String itemType = getItemCommandName(item);
347 Optional<Integer> simplePort = findSimpleProtocolTcpModule(item, format, rate, channels);
349 if (simplePort.isPresent()) {
352 String moduleOptions = itemType + "=" + item.getPaName() + " port=" + simpleTcpPortToTry;
353 if (item instanceof Source && format != null && rate != null && channels != null) {
354 moduleOptions = moduleOptions + String.format(" record=true format=%s rate=%d channels=%d", format,
355 rate.longValue(), channels.intValue());
357 sendRawCommand("load-module module-simple-protocol-tcp " + moduleOptions);
358 simpleTcpPortToTry = new Random().nextInt(64512) + 1024; // a random port above 1024
363 } while (currentTry < 3);
366 The pulseaudio binding tried 3 times to load the module-simple-protocol-tcp\
367 on random port on the pulseaudio server and give up trying\
369 return Optional.empty();
373 * Find a simple protocol module corresponding to the given sink in argument
374 * and returns the port it listens to
379 private Optional<Integer> findSimpleProtocolTcpModule(AbstractAudioDeviceConfig item, @Nullable String format,
380 @Nullable BigDecimal rate, @Nullable BigDecimal channels) {
381 String itemType = getItemCommandName(item);
382 if (itemType == null) {
383 return Optional.empty();
385 List<Module> modulesCopy = new ArrayList<Module>(modules);
386 var isSource = item instanceof Source;
387 return modulesCopy.stream() // iteration on modules
388 .filter(module -> MODULE_SIMPLE_PROTOCOL_TCP_NAME.equals(module.getPaName())) // filter on module name
390 boolean nameMatch = extractArgumentFromLine(itemType, module.getArgument()) // extract sick|source
391 .map(name -> name.equals(item.getPaName())).orElse(false);
392 if (isSource && nameMatch) {
393 boolean recordStream = extractArgumentFromLine("record", module.getArgument())
394 .map("true"::equals).orElse(false);
398 if (format != null) {
399 boolean rateMatch = extractArgumentFromLine("format", module.getArgument())
400 .map(format::equals).orElse(false);
406 boolean rateMatch = extractArgumentFromLine("rate", module.getArgument())
407 .map(value -> Long.parseLong(value) == rate.longValue()).orElse(false);
412 if (channels != null) {
413 boolean channelsMatch = extractArgumentFromLine("channels", module.getArgument())
414 .map(value -> Integer.parseInt(value) == channels.intValue()).orElse(false);
415 if (!channelsMatch) {
421 }) // filter on sink name
422 .findAny() // get a corresponding module
423 .map(module -> extractArgumentFromLine("port", module.getArgument())
424 .orElse(Integer.toString(MODULE_SIMPLE_PROTOCOL_TCP_DEFAULT_PORT))) // get port
425 .map(portS -> Integer.parseInt(portS));
428 private Optional<String> extractArgumentFromLine(String argumentWanted, @Nullable String argumentLine) {
429 String argument = null;
430 if (argumentLine != null) {
431 int startPortIndex = argumentLine.indexOf(argumentWanted + "=");
432 if (startPortIndex != -1) {
433 startPortIndex = startPortIndex + argumentWanted.length() + 1;
434 int endPortIndex = argumentLine.indexOf(" ", startPortIndex);
435 if (endPortIndex == -1) {
436 endPortIndex = argumentLine.length();
438 argument = argumentLine.substring(startPortIndex, endPortIndex);
441 return Optional.ofNullable(argument);
445 * returns the item names that can be used in commands
450 private @Nullable String getItemCommandName(AbstractAudioDeviceConfig item) {
451 if (item instanceof Sink) {
453 } else if (item instanceof Source) {
455 } else if (item instanceof SinkInput) {
456 return ITEM_SINK_INPUT;
457 } else if (item instanceof SourceOutput) {
458 return ITEM_SOURCE_OUTPUT;
464 * change the volume of an {@link AbstractAudioDeviceConfig}
466 * @param item the {@link AbstractAudioDeviceConfig} to handle
467 * @param vol the new volume percent value the {@link AbstractAudioDeviceConfig} should be changed to (possible
468 * values from 0 - 100)
470 public void setVolumePercent(AbstractAudioDeviceConfig item, int vol) {
471 int volumeToSet = vol;
472 if (volumeToSet <= 100) {
473 volumeToSet = toAbsoluteVolume(volumeToSet);
475 setVolume(item, volumeToSet);
479 * transform a percent volume to a value that can be send to the pulseaudio server (0-65536)
484 private int toAbsoluteVolume(int percent) {
485 return (int) Math.round(65536f / 100f * Double.valueOf(percent));
489 * changes the combined sinks slaves to the given <code>sinks</code>
491 * @param combinedSink the combined sink which slaves should be changed
492 * @param sinks the list of new slaves
494 public void setCombinedSinkSlaves(@Nullable Sink combinedSink, List<Sink> sinks) {
495 if (combinedSink == null || !combinedSink.isCombinedSink()) {
498 List<String> slaves = new ArrayList<>();
499 for (Sink sink : sinks) {
500 slaves.add(sink.getPaName());
502 // 1. delete old combined-sink
503 Module lastModule = combinedSink.getModule();
504 if (lastModule != null) {
505 sendRawCommand(CMD_UNLOAD_MODULE + " " + lastModule.getId());
507 // 2. add new combined-sink with same name and all slaves
508 sendRawCommand(CMD_LOAD_MODULE + " " + MODULE_COMBINE_SINK + " sink_name=" + combinedSink.getPaName()
509 + " slaves=" + String.join(",", slaves));
510 // 3. update internal data structure because the combined sink has a new number + other slaves
515 * sets the sink a sink-input should be routed to
517 * @param sinkInput the sink-input to be rerouted
518 * @param sink the new sink the sink-input should be routed to
520 public void moveSinkInput(@Nullable SinkInput sinkInput, @Nullable Sink sink) {
521 if (sinkInput == null || sink == null) {
524 sendRawCommand("move-sink-input " + sinkInput.getId() + " " + sink.getId());
525 sinkInput.setSink(sink);
529 * sets the sink a source-output should be routed to
531 * @param sourceOutput the source-output to be rerouted
532 * @param source the new source the source-output should be routed to
534 public void moveSourceOutput(@Nullable SourceOutput sourceOutput, @Nullable Source source) {
535 if (sourceOutput == null || source == null) {
538 sendRawCommand("move-sink-input " + sourceOutput.getId() + " " + source.getId());
539 sourceOutput.setSource(source);
545 * @param source the source which state should be changed
546 * @param suspend suspend it or not
548 public void suspendSource(@Nullable Source source, boolean suspend) {
549 if (source == null) {
553 sendRawCommand("suspend-source " + source.getId() + " 1");
554 source.setState(State.SUSPENDED);
556 sendRawCommand("suspend-source " + source.getId() + " 0");
557 // unsuspending the source could result in different states (RUNNING,IDLE,...)
558 // update to get the new state
566 * @param sink the sink which state should be changed
567 * @param suspend suspend it or not
569 public void suspendSink(@Nullable Sink sink, boolean suspend) {
574 sendRawCommand("suspend-sink " + sink.getId() + " 1");
575 sink.setState(State.SUSPENDED);
577 sendRawCommand("suspend-sink " + sink.getId() + " 0");
578 // unsuspending the sink could result in different states (RUNNING,IDLE,...)
579 // update to get the new state
585 * changes the combined sinks slaves to the given <code>sinks</code>
587 * @param combinedSinkName the combined sink which slaves should be changed
588 * @param sinks the list of new slaves
590 public void setCombinedSinkSlaves(String combinedSinkName, List<Sink> sinks) {
591 if (getSink(combinedSinkName) != null) {
594 List<String> slaves = new ArrayList<>();
595 for (Sink sink : sinks) {
596 slaves.add(sink.getPaName());
598 // add new combined-sink with same name and all slaves
599 sendRawCommand(CMD_LOAD_MODULE + " " + MODULE_COMBINE_SINK + " sink_name=" + combinedSinkName + " slaves="
600 + String.join(",", slaves));
601 // update internal data structure because the combined sink is new
605 private synchronized void sendRawCommand(String command) {
607 Socket clientSocket = client;
608 if (clientSocket != null && clientSocket.isConnected()) {
610 PrintStream out = new PrintStream(clientSocket.getOutputStream(), true);
611 logger.trace("sending command {} to pa-server {}", command, host);
612 out.print(command + "\r\n");
614 clientSocket.close();
615 } catch (IOException e) {
616 logger.warn("{}", e.getMessage(), e);
621 private String sendRawRequest(String command) {
622 logger.trace("_sendRawRequest({})", command);
625 Socket clientSocket = client;
626 if (clientSocket != null && clientSocket.isConnected()) {
628 PrintStream out = new PrintStream(clientSocket.getOutputStream(), true);
629 out.print(command + "\r\n");
631 InputStream instr = clientSocket.getInputStream();
634 byte[] buff = new byte[1024];
638 retRead = instr.read(buff);
641 String line = new String(buff, 0, retRead);
642 if (line.endsWith(">>> ") && lc > 1) {
643 result += line.substring(0, line.length() - 4);
646 result += line.trim();
648 } while (retRead > 0);
649 } catch (SocketTimeoutException e) {
650 // Timeout -> as newer PA versions (>=5.0) do not send the >>> we have no chance
651 // to detect the end of the answer, except by this timeout
652 } catch (SocketException e) {
653 logger.warn("Socket exception while sending pulseaudio command: {}", e.getMessage());
654 } catch (IOException e) {
655 logger.warn("Exception while reading socket: {}", e.getMessage());
659 clientSocket.close();
661 } catch (IOException e) {
662 logger.warn("{}", e.getMessage(), e);
668 private void checkConnection() {
671 } catch (IOException e) {
672 logger.debug("{}", e.getMessage(), e);
677 * Connects to the pulseaudio server (timeout 500ms)
679 public void connect() throws IOException {
680 Socket clientSocket = client;
681 if (clientSocket == null || clientSocket.isClosed() || !clientSocket.isConnected()) {
682 logger.trace("Try to connect...");
684 var clientFinal = new Socket(host, port);
685 clientFinal.setSoTimeout(500);
686 client = clientFinal;
687 logger.trace("connected");
688 } catch (UnknownHostException e) {
690 throw new IOException("Unknown host", e);
691 } catch (IllegalArgumentException e) {
693 throw new IOException("Invalid port", e);
694 } catch (SecurityException | SocketException e) {
696 throw new IOException(
697 String.format("Cannot connect socket: %s", e.getMessage() != null ? e.getMessage() : ""), e);
698 } catch (IOException e) {
706 * Disconnects from the pulseaudio server
708 public void disconnect() {
709 Socket clientSocket = client;
710 if (clientSocket != null) {
712 clientSocket.close();
713 } catch (IOException e) {
714 logger.debug("{}", e.getMessage(), e);