2 * Copyright (c) 2010-2020 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;
28 import org.apache.commons.lang.StringUtils;
29 import org.openhab.binding.pulseaudio.internal.cli.Parser;
30 import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig;
31 import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig.State;
32 import org.openhab.binding.pulseaudio.internal.items.Module;
33 import org.openhab.binding.pulseaudio.internal.items.Sink;
34 import org.openhab.binding.pulseaudio.internal.items.SinkInput;
35 import org.openhab.binding.pulseaudio.internal.items.Source;
36 import org.openhab.binding.pulseaudio.internal.items.SourceOutput;
37 import org.slf4j.Logger;
38 import org.slf4j.LoggerFactory;
41 * The client connects to a pulseaudio server via TCP. It reads the current state of the
42 * pulseaudio server (available sinks, sources,...) and can send commands to the server.
43 * The syntax of the commands is the same as for the pactl command line tool provided by pulseaudio.
45 * On the pulseaudio server the module-cli-protocol-tcp has to be loaded.
47 * @author Tobias Bräutigam - Initial contribution
49 public class PulseaudioClient {
51 private final Logger logger = LoggerFactory.getLogger(PulseaudioClient.class);
55 private Socket client;
57 private List<AbstractAudioDeviceConfig> items;
58 private List<Module> modules;
61 * corresponding name to execute actions on sink items
63 private static final String ITEM_SINK = "sink";
66 * corresponding name to execute actions on source items
68 private static final String ITEM_SOURCE = "source";
71 * corresponding name to execute actions on sink-input items
73 private static final String ITEM_SINK_INPUT = "sink-input";
76 * corresponding name to execute actions on source-output items
78 private static final String ITEM_SOURCE_OUTPUT = "source-output";
81 * command to list the loaded modules
83 private static final String CMD_LIST_MODULES = "list-modules";
86 * command to list the sinks
88 private static final String CMD_LIST_SINKS = "list-sinks";
91 * command to list the sources
93 private static final String CMD_LIST_SOURCES = "list-sources";
96 * command to list the sink-inputs
98 private static final String CMD_LIST_SINK_INPUTS = "list-sink-inputs";
101 * command to list the source-outputs
103 private static final String CMD_LIST_SOURCE_OUTPUTS = "list-source-outputs";
106 * command to load a module
108 private static final String CMD_LOAD_MODULE = "load-module";
111 * command to unload a module
113 private static final String CMD_UNLOAD_MODULE = "unload-module";
116 * name of the module-combine-sink
118 private static final String MODULE_COMBINE_SINK = "module-combine-sink";
120 public PulseaudioClient() throws IOException {
121 this("localhost", 4712);
124 public PulseaudioClient(String host, int port) throws IOException {
128 items = new ArrayList<>();
129 modules = new ArrayList<>();
135 public boolean isConnected() {
136 return client != null ? client.isConnected() : false;
140 * updates the item states and their relationships
142 public void update() {
144 modules.addAll(Parser.parseModules(listModules()));
147 if (TYPE_FILTERS.get(SINK_THING_TYPE.getId())) {
148 logger.debug("reading sinks");
149 items.addAll(Parser.parseSinks(listSinks(), this));
151 if (TYPE_FILTERS.get(SOURCE_THING_TYPE.getId())) {
152 logger.debug("reading sources");
153 items.addAll(Parser.parseSources(listSources(), this));
155 if (TYPE_FILTERS.get(SINK_INPUT_THING_TYPE.getId())) {
156 logger.debug("reading sink-inputs");
157 items.addAll(Parser.parseSinkInputs(listSinkInputs(), this));
159 if (TYPE_FILTERS.get(SOURCE_OUTPUT_THING_TYPE.getId())) {
160 logger.debug("reading source-outputs");
161 items.addAll(Parser.parseSourceOutputs(listSourceOutputs(), this));
163 logger.debug("Pulseaudio server {}: {} modules and {} items updated", host, modules.size(), items.size());
166 private String listModules() {
167 return this.sendRawRequest(CMD_LIST_MODULES);
170 private String listSinks() {
171 return this.sendRawRequest(CMD_LIST_SINKS);
174 private String listSources() {
175 return this.sendRawRequest(CMD_LIST_SOURCES);
178 private String listSinkInputs() {
179 return this.sendRawRequest(CMD_LIST_SINK_INPUTS);
182 private String listSourceOutputs() {
183 return this.sendRawRequest(CMD_LIST_SOURCE_OUTPUTS);
187 * retrieves a module by its id
190 * @return the corresponding {@link Module} to the given <code>id</code>
192 public Module getModule(int id) {
193 for (Module module : modules) {
194 if (module.getId() == id) {
202 * send the command directly to the pulseaudio server
203 * for a list of available commands please take a look at
204 * http://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/CLI
208 public void sendCommand(String command) {
209 sendRawCommand(command);
213 * retrieves a {@link Sink} by its name
215 * @return the corresponding {@link Sink} to the given <code>name</code>
217 public Sink getSink(String name) {
218 for (AbstractAudioDeviceConfig item : items) {
219 if (item.getPaName().equalsIgnoreCase(name) && item instanceof Sink) {
227 * retrieves a {@link Sink} by its id
229 * @return the corresponding {@link Sink} to the given <code>id</code>
231 public Sink getSink(int id) {
232 for (AbstractAudioDeviceConfig item : items) {
233 if (item.getId() == id && item instanceof Sink) {
241 * retrieves a {@link SinkInput} by its name
243 * @return the corresponding {@link SinkInput} to the given <code>name</code>
245 public SinkInput getSinkInput(String name) {
246 for (AbstractAudioDeviceConfig item : items) {
247 if (item.getPaName().equalsIgnoreCase(name) && item instanceof SinkInput) {
248 return (SinkInput) item;
255 * retrieves a {@link SinkInput} by its id
257 * @return the corresponding {@link SinkInput} to the given <code>id</code>
259 public SinkInput getSinkInput(int id) {
260 for (AbstractAudioDeviceConfig item : items) {
261 if (item.getId() == id && item instanceof SinkInput) {
262 return (SinkInput) item;
269 * retrieves a {@link Source} by its name
271 * @return the corresponding {@link Source} to the given <code>name</code>
273 public Source getSource(String name) {
274 for (AbstractAudioDeviceConfig item : items) {
275 if (item.getPaName().equalsIgnoreCase(name) && item instanceof Source) {
276 return (Source) item;
283 * retrieves a {@link Source} by its id
285 * @return the corresponding {@link Source} to the given <code>id</code>
287 public Source getSource(int id) {
288 for (AbstractAudioDeviceConfig item : items) {
289 if (item.getId() == id && item instanceof Source) {
290 return (Source) item;
297 * retrieves a {@link SourceOutput} by its name
299 * @return the corresponding {@link SourceOutput} to the given <code>name</code>
301 public SourceOutput getSourceOutput(String name) {
302 for (AbstractAudioDeviceConfig item : items) {
303 if (item.getPaName().equalsIgnoreCase(name) && item instanceof SourceOutput) {
304 return (SourceOutput) item;
311 * retrieves a {@link SourceOutput} by its id
313 * @return the corresponding {@link SourceOutput} to the given <code>id</code>
315 public SourceOutput getSourceOutput(int id) {
316 for (AbstractAudioDeviceConfig item : items) {
317 if (item.getId() == id && item instanceof SourceOutput) {
318 return (SourceOutput) item;
325 * retrieves a {@link AbstractAudioDeviceConfig} by its name
327 * @return the corresponding {@link AbstractAudioDeviceConfig} to the given <code>name</code>
329 public AbstractAudioDeviceConfig getGenericAudioItem(String name) {
330 for (AbstractAudioDeviceConfig item : items) {
331 if (item.getPaName().equalsIgnoreCase(name)) {
338 public List<AbstractAudioDeviceConfig> getItems() {
343 * changes the <code>mute</code> state of the corresponding {@link Sink}
345 * @param item the {@link Sink} to handle
346 * @param mute mutes the sink if true, unmutes if false
348 public void setMute(AbstractAudioDeviceConfig item, boolean mute) {
352 String itemCommandName = getItemCommandName(item);
353 if (itemCommandName == null) {
356 String muteString = mute ? "1" : "0";
357 sendRawCommand("set-" + itemCommandName + "-mute " + item.getId() + " " + muteString);
358 // update internal data
363 * change the volume of a {@link AbstractAudioDeviceConfig}
365 * @param item the {@link AbstractAudioDeviceConfig} to handle
366 * @param vol the new volume value the {@link AbstractAudioDeviceConfig} should be changed to (possible values from
369 public void setVolume(AbstractAudioDeviceConfig item, int vol) {
373 String itemCommandName = getItemCommandName(item);
374 if (itemCommandName == null) {
377 sendRawCommand("set-" + itemCommandName + "-volume " + item.getId() + " " + vol);
378 item.setVolume(Math.round(100f / 65536f * vol));
382 * returns the item names that can be used in commands
387 private String getItemCommandName(AbstractAudioDeviceConfig item) {
388 if (item instanceof Sink) {
390 } else if (item instanceof Source) {
392 } else if (item instanceof SinkInput) {
393 return ITEM_SINK_INPUT;
394 } else if (item instanceof SourceOutput) {
395 return ITEM_SOURCE_OUTPUT;
401 * change the volume of a {@link AbstractAudioDeviceConfig}
403 * @param item the {@link AbstractAudioDeviceConfig} to handle
404 * @param vol the new volume percent value the {@link AbstractAudioDeviceConfig} should be changed to (possible
405 * values from 0 - 100)
407 public void setVolumePercent(AbstractAudioDeviceConfig item, int vol) {
412 vol = toAbsoluteVolume(vol);
414 setVolume(item, vol);
418 * transform a percent volume to a value that can be send to the pulseaudio server (0-65536)
423 private int toAbsoluteVolume(int percent) {
424 return (int) Math.round(65536f / 100f * Double.valueOf(percent));
428 * changes the combined sinks slaves to the given <code>sinks</code>
430 * @param combinedSink the combined sink which slaves should be changed
431 * @param sinks the list of new slaves
433 public void setCombinedSinkSlaves(Sink combinedSink, List<Sink> sinks) {
434 if (combinedSink == null || !combinedSink.isCombinedSink()) {
437 List<String> slaves = new ArrayList<>();
438 for (Sink sink : sinks) {
439 slaves.add(sink.getPaName());
441 // 1. delete old combined-sink
442 sendRawCommand(CMD_UNLOAD_MODULE + " " + combinedSink.getModule().getId());
443 // 2. add new combined-sink with same name and all slaves
444 sendRawCommand(CMD_LOAD_MODULE + " " + MODULE_COMBINE_SINK + " sink_name=" + combinedSink.getPaName()
445 + " slaves=" + StringUtils.join(slaves, ","));
446 // 3. update internal data structure because the combined sink has a new number + other slaves
451 * sets the sink a sink-input should be routed to
453 * @param sinkInput the sink-input to be rerouted
454 * @param sink the new sink the sink-input should be routed to
456 public void moveSinkInput(SinkInput sinkInput, Sink sink) {
457 if (sinkInput == null || sink == null) {
460 sendRawCommand("move-sink-input " + sinkInput.getId() + " " + sink.getId());
461 sinkInput.setSink(sink);
465 * sets the sink a source-output should be routed to
467 * @param sourceOutput the source-output to be rerouted
468 * @param source the new source the source-output should be routed to
470 public void moveSourceOutput(SourceOutput sourceOutput, Source source) {
471 if (sourceOutput == null || source == null) {
474 sendRawCommand("move-sink-input " + sourceOutput.getId() + " " + source.getId());
475 sourceOutput.setSource(source);
481 * @param source the source which state should be changed
482 * @param suspend suspend it or not
484 public void suspendSource(Source source, boolean suspend) {
485 if (source == null) {
489 sendRawCommand("suspend-source " + source.getId() + " 1");
490 source.setState(State.SUSPENDED);
492 sendRawCommand("suspend-source " + source.getId() + " 0");
493 // unsuspending the source could result in different states (RUNNING,IDLE,...)
494 // update to get the new state
502 * @param sink the sink which state should be changed
503 * @param suspend suspend it or not
505 public void suspendSink(Sink sink, boolean suspend) {
510 sendRawCommand("suspend-sink " + sink.getId() + " 1");
511 sink.setState(State.SUSPENDED);
513 sendRawCommand("suspend-sink " + sink.getId() + " 0");
514 // unsuspending the sink could result in different states (RUNNING,IDLE,...)
515 // update to get the new state
521 * changes the combined sinks slaves to the given <code>sinks</code>
523 * @param combinedSinkName the combined sink which slaves should be changed
524 * @param sinks the list of new slaves
526 public void setCombinedSinkSlaves(String combinedSinkName, List<Sink> sinks) {
527 if (getSink(combinedSinkName) != null) {
530 List<String> slaves = new ArrayList<>();
531 for (Sink sink : sinks) {
532 slaves.add(sink.getPaName());
534 // add new combined-sink with same name and all slaves
535 sendRawCommand(CMD_LOAD_MODULE + " " + MODULE_COMBINE_SINK + " sink_name=" + combinedSinkName + " slaves="
536 + StringUtils.join(slaves, ","));
537 // update internal data structure because the combined sink is new
541 private void sendRawCommand(String command) {
543 if (client != null) {
545 PrintStream out = new PrintStream(client.getOutputStream(), true);
546 logger.trace("sending command {} to pa-server {}", command, host);
547 out.print(command + "\r\n");
550 } catch (IOException e) {
551 logger.error("{}", e.getLocalizedMessage(), e);
556 private String sendRawRequest(String command) {
557 logger.trace("_sendRawRequest({})", command);
560 if (client != null) {
562 PrintStream out = new PrintStream(client.getOutputStream(), true);
563 out.print(command + "\r\n");
565 InputStream instr = client.getInputStream();
568 byte[] buff = new byte[1024];
572 retRead = instr.read(buff);
575 String line = new String(buff, 0, retRead);
576 // System.out.println("'"+line+"'");
577 if (line.endsWith(">>> ") && lc > 1) {
578 result += line.substring(0, line.length() - 4);
581 result += line.trim();
583 } while (retRead > 0);
584 } catch (SocketTimeoutException e) {
585 // Timeout -> as newer PA versions (>=5.0) do not send the >>> we have no chance
586 // to detect the end of the answer, except by this timeout
587 } catch (IOException e) {
588 logger.error("Exception while reading socket: {}", e.getMessage());
594 } catch (IOException e) {
595 logger.error("{}", e.getLocalizedMessage(), e);
601 private void checkConnection() {
602 if (client == null || client.isClosed() || !client.isConnected()) {
605 } catch (IOException e) {
606 logger.error("{}", e.getLocalizedMessage(), e);
612 * Connects to the pulseaudio server (timeout 500ms)
614 private void connect() throws IOException {
616 client = new Socket(host, port);
617 client.setSoTimeout(500);
618 } catch (UnknownHostException e) {
619 logger.error("unknown socket host {}", host);
620 } catch (NoRouteToHostException e) {
621 logger.error("no route to host {}", host);
622 } catch (SocketException e) {
623 logger.error("{}", e.getLocalizedMessage(), e);
628 * Disconnects from the pulseaudio server
630 public void disconnect() {
631 if (client != null) {
634 } catch (IOException e) {
635 logger.error("{}", e.getLocalizedMessage(), e);