]> git.basschouten.com Git - openhab-addons.git/blob
0204c6b958a213a6bdd1e8d546c0c86460760a48
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.pulseaudio.internal;
14
15 import static org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants.*;
16
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;
29
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;
41
42 /**
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.
46  *
47  * On the pulseaudio server the module-cli-protocol-tcp has to be loaded.
48  *
49  * @author Tobias Bräutigam - Initial contribution
50  */
51 public class PulseaudioClient {
52
53     private final Logger logger = LoggerFactory.getLogger(PulseaudioClient.class);
54
55     private String host;
56     private int port;
57     private Socket client;
58
59     private List<AbstractAudioDeviceConfig> items;
60     private List<Module> modules;
61
62     /**
63      * Corresponding to the global binding configuration
64      */
65     private PulseAudioBindingConfiguration configuration;
66
67     /**
68      * corresponding name to execute actions on sink items
69      */
70     private static final String ITEM_SINK = "sink";
71
72     /**
73      * corresponding name to execute actions on source items
74      */
75     private static final String ITEM_SOURCE = "source";
76
77     /**
78      * corresponding name to execute actions on sink-input items
79      */
80     private static final String ITEM_SINK_INPUT = "sink-input";
81
82     /**
83      * corresponding name to execute actions on source-output items
84      */
85     private static final String ITEM_SOURCE_OUTPUT = "source-output";
86
87     /**
88      * command to list the loaded modules
89      */
90     private static final String CMD_LIST_MODULES = "list-modules";
91
92     /**
93      * command to list the sinks
94      */
95     private static final String CMD_LIST_SINKS = "list-sinks";
96
97     /**
98      * command to list the sources
99      */
100     private static final String CMD_LIST_SOURCES = "list-sources";
101
102     /**
103      * command to list the sink-inputs
104      */
105     private static final String CMD_LIST_SINK_INPUTS = "list-sink-inputs";
106
107     /**
108      * command to list the source-outputs
109      */
110     private static final String CMD_LIST_SOURCE_OUTPUTS = "list-source-outputs";
111
112     /**
113      * command to load a module
114      */
115     private static final String CMD_LOAD_MODULE = "load-module";
116
117     /**
118      * command to unload a module
119      */
120     private static final String CMD_UNLOAD_MODULE = "unload-module";
121
122     /**
123      * name of the module-combine-sink
124      */
125     private static final String MODULE_COMBINE_SINK = "module-combine-sink";
126
127     public PulseaudioClient(String host, int port, PulseAudioBindingConfiguration configuration) throws IOException {
128         this.host = host;
129         this.port = port;
130         this.configuration = configuration;
131
132         items = new ArrayList<>();
133         modules = new ArrayList<>();
134
135         connect();
136         update();
137     }
138
139     public boolean isConnected() {
140         return client != null ? client.isConnected() : false;
141     }
142
143     /**
144      * updates the item states and their relationships
145      */
146     public synchronized void update() {
147         // one step copy
148         modules = new ArrayList<Module>(Parser.parseModules(listModules()));
149
150         List<AbstractAudioDeviceConfig> newItems = new ArrayList<>(); // prepare new list before assigning it
151         newItems.clear();
152         if (configuration.sink) {
153             logger.debug("reading sinks");
154             newItems.addAll(Parser.parseSinks(listSinks(), this));
155         }
156         if (configuration.source) {
157             logger.debug("reading sources");
158             newItems.addAll(Parser.parseSources(listSources(), this));
159         }
160         if (configuration.sinkInput) {
161             logger.debug("reading sink-inputs");
162             newItems.addAll(Parser.parseSinkInputs(listSinkInputs(), this));
163         }
164         if (configuration.sourceOutput) {
165             logger.debug("reading source-outputs");
166             newItems.addAll(Parser.parseSourceOutputs(listSourceOutputs(), this));
167         }
168         logger.debug("Pulseaudio server {}: {} modules and {} items updated", host, modules.size(), newItems.size());
169         items = newItems;
170     }
171
172     private String listModules() {
173         return this.sendRawRequest(CMD_LIST_MODULES);
174     }
175
176     private String listSinks() {
177         return this.sendRawRequest(CMD_LIST_SINKS);
178     }
179
180     private String listSources() {
181         return this.sendRawRequest(CMD_LIST_SOURCES);
182     }
183
184     private String listSinkInputs() {
185         return this.sendRawRequest(CMD_LIST_SINK_INPUTS);
186     }
187
188     private String listSourceOutputs() {
189         return this.sendRawRequest(CMD_LIST_SOURCE_OUTPUTS);
190     }
191
192     /**
193      * retrieves a module by its id
194      *
195      * @param id
196      * @return the corresponding {@link Module} to the given <code>id</code>
197      */
198     public Module getModule(int id) {
199         for (Module module : modules) {
200             if (module.getId() == id) {
201                 return module;
202             }
203         }
204         return null;
205     }
206
207     /**
208      * send the command directly to the pulseaudio server
209      * for a list of available commands please take a look at
210      * http://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/CLI
211      *
212      * @param command
213      */
214     public void sendCommand(String command) {
215         sendRawCommand(command);
216     }
217
218     /**
219      * retrieves a {@link Sink} by its name
220      *
221      * @return the corresponding {@link Sink} to the given <code>name</code>
222      */
223     public Sink getSink(String name) {
224         for (AbstractAudioDeviceConfig item : items) {
225             if (item.getPaName().equalsIgnoreCase(name) && item instanceof Sink) {
226                 return (Sink) item;
227             }
228         }
229         return null;
230     }
231
232     /**
233      * retrieves a {@link Sink} by its id
234      *
235      * @return the corresponding {@link Sink} to the given <code>id</code>
236      */
237     public Sink getSink(int id) {
238         for (AbstractAudioDeviceConfig item : items) {
239             if (item.getId() == id && item instanceof Sink) {
240                 return (Sink) item;
241             }
242         }
243         return null;
244     }
245
246     /**
247      * retrieves a {@link SinkInput} by its name
248      *
249      * @return the corresponding {@link SinkInput} to the given <code>name</code>
250      */
251     public SinkInput getSinkInput(String name) {
252         for (AbstractAudioDeviceConfig item : items) {
253             if (item.getPaName().equalsIgnoreCase(name) && item instanceof SinkInput) {
254                 return (SinkInput) item;
255             }
256         }
257         return null;
258     }
259
260     /**
261      * retrieves a {@link SinkInput} by its id
262      *
263      * @return the corresponding {@link SinkInput} to the given <code>id</code>
264      */
265     public SinkInput getSinkInput(int id) {
266         for (AbstractAudioDeviceConfig item : items) {
267             if (item.getId() == id && item instanceof SinkInput) {
268                 return (SinkInput) item;
269             }
270         }
271         return null;
272     }
273
274     /**
275      * retrieves a {@link Source} by its name
276      *
277      * @return the corresponding {@link Source} to the given <code>name</code>
278      */
279     public Source getSource(String name) {
280         for (AbstractAudioDeviceConfig item : items) {
281             if (item.getPaName().equalsIgnoreCase(name) && item instanceof Source) {
282                 return (Source) item;
283             }
284         }
285         return null;
286     }
287
288     /**
289      * retrieves a {@link Source} by its id
290      *
291      * @return the corresponding {@link Source} to the given <code>id</code>
292      */
293     public Source getSource(int id) {
294         for (AbstractAudioDeviceConfig item : items) {
295             if (item.getId() == id && item instanceof Source) {
296                 return (Source) item;
297             }
298         }
299         return null;
300     }
301
302     /**
303      * retrieves a {@link SourceOutput} by its name
304      *
305      * @return the corresponding {@link SourceOutput} to the given <code>name</code>
306      */
307     public SourceOutput getSourceOutput(String name) {
308         for (AbstractAudioDeviceConfig item : items) {
309             if (item.getPaName().equalsIgnoreCase(name) && item instanceof SourceOutput) {
310                 return (SourceOutput) item;
311             }
312         }
313         return null;
314     }
315
316     /**
317      * retrieves a {@link SourceOutput} by its id
318      *
319      * @return the corresponding {@link SourceOutput} to the given <code>id</code>
320      */
321     public SourceOutput getSourceOutput(int id) {
322         for (AbstractAudioDeviceConfig item : items) {
323             if (item.getId() == id && item instanceof SourceOutput) {
324                 return (SourceOutput) item;
325             }
326         }
327         return null;
328     }
329
330     /**
331      * retrieves a {@link AbstractAudioDeviceConfig} by its name
332      *
333      * @return the corresponding {@link AbstractAudioDeviceConfig} to the given <code>name</code>
334      */
335     public AbstractAudioDeviceConfig getGenericAudioItem(String name) {
336         for (AbstractAudioDeviceConfig item : items) {
337             if (item.getPaName().equalsIgnoreCase(name)) {
338                 return item;
339             }
340         }
341         return null;
342     }
343
344     public List<AbstractAudioDeviceConfig> getItems() {
345         return items;
346     }
347
348     /**
349      * changes the <code>mute</code> state of the corresponding {@link Sink}
350      *
351      * @param item the {@link Sink} to handle
352      * @param mute mutes the sink if true, unmutes if false
353      */
354     public void setMute(AbstractAudioDeviceConfig item, boolean mute) {
355         if (item == null) {
356             return;
357         }
358         String itemCommandName = getItemCommandName(item);
359         if (itemCommandName == null) {
360             return;
361         }
362         String muteString = mute ? "1" : "0";
363         sendRawCommand("set-" + itemCommandName + "-mute " + item.getId() + " " + muteString);
364         // update internal data
365         item.setMuted(mute);
366     }
367
368     /**
369      * change the volume of a {@link AbstractAudioDeviceConfig}
370      *
371      * @param item the {@link AbstractAudioDeviceConfig} to handle
372      * @param vol the new volume value the {@link AbstractAudioDeviceConfig} should be changed to (possible values from
373      *            0 - 65536)
374      */
375     public void setVolume(AbstractAudioDeviceConfig item, int vol) {
376         if (item == null) {
377             return;
378         }
379         String itemCommandName = getItemCommandName(item);
380         if (itemCommandName == null) {
381             return;
382         }
383         sendRawCommand("set-" + itemCommandName + "-volume " + item.getId() + " " + vol);
384         item.setVolume(Math.round(100f / 65536f * vol));
385     }
386
387     /**
388      * Locate or load (if needed) the simple protocol tcp module for the given sink
389      * and returns the port.
390      * The module loading (if needed) will be tried several times, on a new random port each time.
391      *
392      * @param item the sink we are searching for
393      * @param simpleTcpPortPref the port to use if we have to load the module
394      * @return the port on which the module is listening
395      * @throws InterruptedException
396      */
397     public Optional<Integer> loadModuleSimpleProtocolTcpIfNeeded(AbstractAudioDeviceConfig item,
398             Integer simpleTcpPortPref) throws InterruptedException {
399         int currentTry = 0;
400         int simpleTcpPortToTry = simpleTcpPortPref;
401         do {
402             Optional<Integer> simplePort = findSimpleProtocolTcpModule(item);
403
404             if (simplePort.isPresent()) {
405                 return simplePort;
406             } else {
407                 sendRawCommand("load-module module-simple-protocol-tcp sink=" + item.getPaName() + " port="
408                         + simpleTcpPortToTry);
409                 simpleTcpPortToTry = new Random().nextInt(64512) + 1024; // a random port above 1024
410             }
411             Thread.sleep(100);
412             currentTry++;
413         } while (currentTry < 3);
414
415         logger.warn("The pulseaudio binding tried 3 times to load the module-simple-protocol-tcp"
416                 + " on random port on the pulseaudio server and give up trying");
417         return Optional.empty();
418     }
419
420     /**
421      * Find a simple protocol module corresponding to the given sink in argument
422      * and returns the port it listens to
423      *
424      * @param item
425      * @return
426      */
427     private Optional<Integer> findSimpleProtocolTcpModule(AbstractAudioDeviceConfig item) {
428         update();
429
430         List<Module> modulesCopy = new ArrayList<Module>(modules);
431         return modulesCopy.stream() // iteration on modules
432                 .filter(module -> MODULE_SIMPLE_PROTOCOL_TCP_NAME.equals(module.getPaName())) // filter on module name
433                 .filter(module -> extractArgumentFromLine("sink", module.getArgument()) // extract sink in argument
434                         .map(sinkName -> sinkName.equals(item.getPaName())).orElse(false)) // filter on sink name
435                 .findAny() // get a corresponding module
436                 .map(module -> extractArgumentFromLine("port", module.getArgument())
437                         .orElse(Integer.toString(MODULE_SIMPLE_PROTOCOL_TCP_DEFAULT_PORT))) // get port
438                 .map(portS -> Integer.parseInt(portS));
439     }
440
441     private @NonNull Optional<@NonNull String> extractArgumentFromLine(String argumentWanted, String argumentLine) {
442         String argument = null;
443         int startPortIndex = argumentLine.indexOf(argumentWanted + "=");
444         if (startPortIndex != -1) {
445             startPortIndex = startPortIndex + argumentWanted.length() + 1;
446             int endPortIndex = argumentLine.indexOf(" ", startPortIndex);
447             if (endPortIndex == -1) {
448                 endPortIndex = argumentLine.length();
449             }
450             argument = argumentLine.substring(startPortIndex, endPortIndex);
451         }
452         return Optional.ofNullable(argument);
453     }
454
455     /**
456      * returns the item names that can be used in commands
457      *
458      * @param item
459      * @return
460      */
461     private String getItemCommandName(AbstractAudioDeviceConfig item) {
462         if (item instanceof Sink) {
463             return ITEM_SINK;
464         } else if (item instanceof Source) {
465             return ITEM_SOURCE;
466         } else if (item instanceof SinkInput) {
467             return ITEM_SINK_INPUT;
468         } else if (item instanceof SourceOutput) {
469             return ITEM_SOURCE_OUTPUT;
470         }
471         return null;
472     }
473
474     /**
475      * change the volume of a {@link AbstractAudioDeviceConfig}
476      *
477      * @param item the {@link AbstractAudioDeviceConfig} to handle
478      * @param vol the new volume percent value the {@link AbstractAudioDeviceConfig} should be changed to (possible
479      *            values from 0 - 100)
480      */
481     public void setVolumePercent(AbstractAudioDeviceConfig item, int vol) {
482         int volumeToSet = vol;
483         if (item == null) {
484             return;
485         }
486         if (volumeToSet <= 100) {
487             volumeToSet = toAbsoluteVolume(volumeToSet);
488         }
489         setVolume(item, volumeToSet);
490     }
491
492     /**
493      * transform a percent volume to a value that can be send to the pulseaudio server (0-65536)
494      *
495      * @param percent
496      * @return
497      */
498     private int toAbsoluteVolume(int percent) {
499         return (int) Math.round(65536f / 100f * Double.valueOf(percent));
500     }
501
502     /**
503      * changes the combined sinks slaves to the given <code>sinks</code>
504      *
505      * @param combinedSink the combined sink which slaves should be changed
506      * @param sinks the list of new slaves
507      */
508     public void setCombinedSinkSlaves(Sink combinedSink, List<Sink> sinks) {
509         if (combinedSink == null || !combinedSink.isCombinedSink()) {
510             return;
511         }
512         List<String> slaves = new ArrayList<>();
513         for (Sink sink : sinks) {
514             slaves.add(sink.getPaName());
515         }
516         // 1. delete old combined-sink
517         sendRawCommand(CMD_UNLOAD_MODULE + " " + combinedSink.getModule().getId());
518         // 2. add new combined-sink with same name and all slaves
519         sendRawCommand(CMD_LOAD_MODULE + " " + MODULE_COMBINE_SINK + " sink_name=" + combinedSink.getPaName()
520                 + " slaves=" + String.join(",", slaves));
521         // 3. update internal data structure because the combined sink has a new number + other slaves
522         update();
523     }
524
525     /**
526      * sets the sink a sink-input should be routed to
527      *
528      * @param sinkInput the sink-input to be rerouted
529      * @param sink the new sink the sink-input should be routed to
530      */
531     public void moveSinkInput(SinkInput sinkInput, Sink sink) {
532         if (sinkInput == null || sink == null) {
533             return;
534         }
535         sendRawCommand("move-sink-input " + sinkInput.getId() + " " + sink.getId());
536         sinkInput.setSink(sink);
537     }
538
539     /**
540      * sets the sink a source-output should be routed to
541      *
542      * @param sourceOutput the source-output to be rerouted
543      * @param source the new source the source-output should be routed to
544      */
545     public void moveSourceOutput(SourceOutput sourceOutput, Source source) {
546         if (sourceOutput == null || source == null) {
547             return;
548         }
549         sendRawCommand("move-sink-input " + sourceOutput.getId() + " " + source.getId());
550         sourceOutput.setSource(source);
551     }
552
553     /**
554      * suspend a source
555      *
556      * @param source the source which state should be changed
557      * @param suspend suspend it or not
558      */
559     public void suspendSource(Source source, boolean suspend) {
560         if (source == null) {
561             return;
562         }
563         if (suspend) {
564             sendRawCommand("suspend-source " + source.getId() + " 1");
565             source.setState(State.SUSPENDED);
566         } else {
567             sendRawCommand("suspend-source " + source.getId() + " 0");
568             // unsuspending the source could result in different states (RUNNING,IDLE,...)
569             // update to get the new state
570             update();
571         }
572     }
573
574     /**
575      * suspend a sink
576      *
577      * @param sink the sink which state should be changed
578      * @param suspend suspend it or not
579      */
580     public void suspendSink(Sink sink, boolean suspend) {
581         if (sink == null) {
582             return;
583         }
584         if (suspend) {
585             sendRawCommand("suspend-sink " + sink.getId() + " 1");
586             sink.setState(State.SUSPENDED);
587         } else {
588             sendRawCommand("suspend-sink " + sink.getId() + " 0");
589             // unsuspending the sink could result in different states (RUNNING,IDLE,...)
590             // update to get the new state
591             update();
592         }
593     }
594
595     /**
596      * changes the combined sinks slaves to the given <code>sinks</code>
597      *
598      * @param combinedSinkName the combined sink which slaves should be changed
599      * @param sinks the list of new slaves
600      */
601     public void setCombinedSinkSlaves(String combinedSinkName, List<Sink> sinks) {
602         if (getSink(combinedSinkName) != null) {
603             return;
604         }
605         List<String> slaves = new ArrayList<>();
606         for (Sink sink : sinks) {
607             slaves.add(sink.getPaName());
608         }
609         // add new combined-sink with same name and all slaves
610         sendRawCommand(CMD_LOAD_MODULE + " " + MODULE_COMBINE_SINK + " sink_name=" + combinedSinkName + " slaves="
611                 + String.join(",", slaves));
612         // update internal data structure because the combined sink is new
613         update();
614     }
615
616     private void sendRawCommand(String command) {
617         checkConnection();
618         if (client != null) {
619             try {
620                 PrintStream out = new PrintStream(client.getOutputStream(), true);
621                 logger.trace("sending command {} to pa-server {}", command, host);
622                 out.print(command + "\r\n");
623                 out.close();
624                 client.close();
625             } catch (IOException e) {
626                 logger.error("{}", e.getLocalizedMessage(), e);
627             }
628         }
629     }
630
631     private String sendRawRequest(String command) {
632         logger.trace("_sendRawRequest({})", command);
633         checkConnection();
634         String result = "";
635         if (client != null) {
636             try {
637                 PrintStream out = new PrintStream(client.getOutputStream(), true);
638                 out.print(command + "\r\n");
639
640                 InputStream instr = client.getInputStream();
641
642                 try {
643                     byte[] buff = new byte[1024];
644                     int retRead = 0;
645                     int lc = 0;
646                     do {
647                         retRead = instr.read(buff);
648                         lc++;
649                         if (retRead > 0) {
650                             String line = new String(buff, 0, retRead);
651                             // System.out.println("'"+line+"'");
652                             if (line.endsWith(">>> ") && lc > 1) {
653                                 result += line.substring(0, line.length() - 4);
654                                 break;
655                             }
656                             result += line.trim();
657                         }
658                     } while (retRead > 0);
659                 } catch (SocketTimeoutException e) {
660                     // Timeout -> as newer PA versions (>=5.0) do not send the >>> we have no chance
661                     // to detect the end of the answer, except by this timeout
662                 } catch (SocketException e) {
663                     logger.warn("Socket exception while sending pulseaudio command: {}", e.getMessage());
664                 } catch (IOException e) {
665                     logger.error("Exception while reading socket: {}", e.getMessage());
666                 }
667                 instr.close();
668                 out.close();
669                 client.close();
670                 return result;
671             } catch (IOException e) {
672                 logger.error("{}", e.getLocalizedMessage(), e);
673             }
674         }
675         return result;
676     }
677
678     private void checkConnection() {
679         if (client == null || client.isClosed() || !client.isConnected()) {
680             try {
681                 connect();
682             } catch (IOException e) {
683                 logger.error("{}", e.getLocalizedMessage(), e);
684             }
685         }
686     }
687
688     /**
689      * Connects to the pulseaudio server (timeout 500ms)
690      */
691     private void connect() throws IOException {
692         try {
693             client = new Socket(host, port);
694             client.setSoTimeout(500);
695         } catch (UnknownHostException e) {
696             logger.error("unknown socket host {}", host);
697         } catch (NoRouteToHostException e) {
698             logger.error("no route to host {}", host);
699         } catch (SocketException e) {
700             logger.error("cannot connect to host {} : {}", host, e.getMessage());
701         }
702     }
703
704     /**
705      * Disconnects from the pulseaudio server
706      */
707     public void disconnect() {
708         if (client != null) {
709             try {
710                 client.close();
711             } catch (IOException e) {
712                 logger.error("{}", e.getLocalizedMessage(), e);
713             }
714         }
715     }
716 }