]> git.basschouten.com Git - openhab-addons.git/blob
54cbc552b2a213093cb09644dc421ac01f0e2849
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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 name to execute actions on sink items
64      */
65     private static final String ITEM_SINK = "sink";
66
67     /**
68      * corresponding name to execute actions on source items
69      */
70     private static final String ITEM_SOURCE = "source";
71
72     /**
73      * corresponding name to execute actions on sink-input items
74      */
75     private static final String ITEM_SINK_INPUT = "sink-input";
76
77     /**
78      * corresponding name to execute actions on source-output items
79      */
80     private static final String ITEM_SOURCE_OUTPUT = "source-output";
81
82     /**
83      * command to list the loaded modules
84      */
85     private static final String CMD_LIST_MODULES = "list-modules";
86
87     /**
88      * command to list the sinks
89      */
90     private static final String CMD_LIST_SINKS = "list-sinks";
91
92     /**
93      * command to list the sources
94      */
95     private static final String CMD_LIST_SOURCES = "list-sources";
96
97     /**
98      * command to list the sink-inputs
99      */
100     private static final String CMD_LIST_SINK_INPUTS = "list-sink-inputs";
101
102     /**
103      * command to list the source-outputs
104      */
105     private static final String CMD_LIST_SOURCE_OUTPUTS = "list-source-outputs";
106
107     /**
108      * command to load a module
109      */
110     private static final String CMD_LOAD_MODULE = "load-module";
111
112     /**
113      * command to unload a module
114      */
115     private static final String CMD_UNLOAD_MODULE = "unload-module";
116
117     /**
118      * name of the module-combine-sink
119      */
120     private static final String MODULE_COMBINE_SINK = "module-combine-sink";
121
122     public PulseaudioClient() throws IOException {
123         this("localhost", 4712);
124     }
125
126     public PulseaudioClient(String host, int port) throws IOException {
127         this.host = host;
128         this.port = port;
129
130         items = new ArrayList<>();
131         modules = new ArrayList<>();
132
133         connect();
134         update();
135     }
136
137     public boolean isConnected() {
138         return client != null ? client.isConnected() : false;
139     }
140
141     /**
142      * updates the item states and their relationships
143      */
144     public synchronized void update() {
145         // one step copy
146         modules = new ArrayList<Module>(Parser.parseModules(listModules()));
147
148         List<AbstractAudioDeviceConfig> newItems = new ArrayList<>(); // prepare new list before assigning it
149         newItems.clear();
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));
153         }
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));
157         }
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));
161         }
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));
165         }
166         logger.debug("Pulseaudio server {}: {} modules and {} items updated", host, modules.size(), newItems.size());
167         items = newItems;
168     }
169
170     private String listModules() {
171         return this.sendRawRequest(CMD_LIST_MODULES);
172     }
173
174     private String listSinks() {
175         return this.sendRawRequest(CMD_LIST_SINKS);
176     }
177
178     private String listSources() {
179         return this.sendRawRequest(CMD_LIST_SOURCES);
180     }
181
182     private String listSinkInputs() {
183         return this.sendRawRequest(CMD_LIST_SINK_INPUTS);
184     }
185
186     private String listSourceOutputs() {
187         return this.sendRawRequest(CMD_LIST_SOURCE_OUTPUTS);
188     }
189
190     /**
191      * retrieves a module by its id
192      *
193      * @param id
194      * @return the corresponding {@link Module} to the given <code>id</code>
195      */
196     public Module getModule(int id) {
197         for (Module module : modules) {
198             if (module.getId() == id) {
199                 return module;
200             }
201         }
202         return null;
203     }
204
205     /**
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
209      *
210      * @param command
211      */
212     public void sendCommand(String command) {
213         sendRawCommand(command);
214     }
215
216     /**
217      * retrieves a {@link Sink} by its name
218      *
219      * @return the corresponding {@link Sink} to the given <code>name</code>
220      */
221     public Sink getSink(String name) {
222         for (AbstractAudioDeviceConfig item : items) {
223             if (item.getPaName().equalsIgnoreCase(name) && item instanceof Sink) {
224                 return (Sink) item;
225             }
226         }
227         return null;
228     }
229
230     /**
231      * retrieves a {@link Sink} by its id
232      *
233      * @return the corresponding {@link Sink} to the given <code>id</code>
234      */
235     public Sink getSink(int id) {
236         for (AbstractAudioDeviceConfig item : items) {
237             if (item.getId() == id && item instanceof Sink) {
238                 return (Sink) item;
239             }
240         }
241         return null;
242     }
243
244     /**
245      * retrieves a {@link SinkInput} by its name
246      *
247      * @return the corresponding {@link SinkInput} to the given <code>name</code>
248      */
249     public SinkInput getSinkInput(String name) {
250         for (AbstractAudioDeviceConfig item : items) {
251             if (item.getPaName().equalsIgnoreCase(name) && item instanceof SinkInput) {
252                 return (SinkInput) item;
253             }
254         }
255         return null;
256     }
257
258     /**
259      * retrieves a {@link SinkInput} by its id
260      *
261      * @return the corresponding {@link SinkInput} to the given <code>id</code>
262      */
263     public SinkInput getSinkInput(int id) {
264         for (AbstractAudioDeviceConfig item : items) {
265             if (item.getId() == id && item instanceof SinkInput) {
266                 return (SinkInput) item;
267             }
268         }
269         return null;
270     }
271
272     /**
273      * retrieves a {@link Source} by its name
274      *
275      * @return the corresponding {@link Source} to the given <code>name</code>
276      */
277     public Source getSource(String name) {
278         for (AbstractAudioDeviceConfig item : items) {
279             if (item.getPaName().equalsIgnoreCase(name) && item instanceof Source) {
280                 return (Source) item;
281             }
282         }
283         return null;
284     }
285
286     /**
287      * retrieves a {@link Source} by its id
288      *
289      * @return the corresponding {@link Source} to the given <code>id</code>
290      */
291     public Source getSource(int id) {
292         for (AbstractAudioDeviceConfig item : items) {
293             if (item.getId() == id && item instanceof Source) {
294                 return (Source) item;
295             }
296         }
297         return null;
298     }
299
300     /**
301      * retrieves a {@link SourceOutput} by its name
302      *
303      * @return the corresponding {@link SourceOutput} to the given <code>name</code>
304      */
305     public SourceOutput getSourceOutput(String name) {
306         for (AbstractAudioDeviceConfig item : items) {
307             if (item.getPaName().equalsIgnoreCase(name) && item instanceof SourceOutput) {
308                 return (SourceOutput) item;
309             }
310         }
311         return null;
312     }
313
314     /**
315      * retrieves a {@link SourceOutput} by its id
316      *
317      * @return the corresponding {@link SourceOutput} to the given <code>id</code>
318      */
319     public SourceOutput getSourceOutput(int id) {
320         for (AbstractAudioDeviceConfig item : items) {
321             if (item.getId() == id && item instanceof SourceOutput) {
322                 return (SourceOutput) item;
323             }
324         }
325         return null;
326     }
327
328     /**
329      * retrieves a {@link AbstractAudioDeviceConfig} by its name
330      *
331      * @return the corresponding {@link AbstractAudioDeviceConfig} to the given <code>name</code>
332      */
333     public AbstractAudioDeviceConfig getGenericAudioItem(String name) {
334         for (AbstractAudioDeviceConfig item : items) {
335             if (item.getPaName().equalsIgnoreCase(name)) {
336                 return item;
337             }
338         }
339         return null;
340     }
341
342     public List<AbstractAudioDeviceConfig> getItems() {
343         return items;
344     }
345
346     /**
347      * changes the <code>mute</code> state of the corresponding {@link Sink}
348      *
349      * @param item the {@link Sink} to handle
350      * @param mute mutes the sink if true, unmutes if false
351      */
352     public void setMute(AbstractAudioDeviceConfig item, boolean mute) {
353         if (item == null) {
354             return;
355         }
356         String itemCommandName = getItemCommandName(item);
357         if (itemCommandName == null) {
358             return;
359         }
360         String muteString = mute ? "1" : "0";
361         sendRawCommand("set-" + itemCommandName + "-mute " + item.getId() + " " + muteString);
362         // update internal data
363         item.setMuted(mute);
364     }
365
366     /**
367      * change the volume of a {@link AbstractAudioDeviceConfig}
368      *
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
371      *            0 - 65536)
372      */
373     public void setVolume(AbstractAudioDeviceConfig item, int vol) {
374         if (item == null) {
375             return;
376         }
377         String itemCommandName = getItemCommandName(item);
378         if (itemCommandName == null) {
379             return;
380         }
381         sendRawCommand("set-" + itemCommandName + "-volume " + item.getId() + " " + vol);
382         item.setVolume(Math.round(100f / 65536f * vol));
383     }
384
385     /**
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.
389      *
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
394      */
395     public Optional<Integer> loadModuleSimpleProtocolTcpIfNeeded(AbstractAudioDeviceConfig item,
396             Integer simpleTcpPortPref) throws InterruptedException {
397         int currentTry = 0;
398         int simpleTcpPortToTry = simpleTcpPortPref;
399         do {
400             Optional<Integer> simplePort = findSimpleProtocolTcpModule(item);
401
402             if (simplePort.isPresent()) {
403                 return simplePort;
404             } else {
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
408             }
409             Thread.sleep(100);
410             currentTry++;
411         } while (currentTry < 3);
412
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();
416     }
417
418     /**
419      * Find a simple protocol module corresponding to the given sink in argument
420      * and returns the port it listens to
421      *
422      * @param item
423      * @return
424      */
425     private Optional<Integer> findSimpleProtocolTcpModule(AbstractAudioDeviceConfig item) {
426         update();
427
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));
437     }
438
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();
447             }
448             argument = argumentLine.substring(startPortIndex, endPortIndex);
449         }
450         return Optional.ofNullable(argument);
451     }
452
453     /**
454      * returns the item names that can be used in commands
455      *
456      * @param item
457      * @return
458      */
459     private String getItemCommandName(AbstractAudioDeviceConfig item) {
460         if (item instanceof Sink) {
461             return ITEM_SINK;
462         } else if (item instanceof Source) {
463             return ITEM_SOURCE;
464         } else if (item instanceof SinkInput) {
465             return ITEM_SINK_INPUT;
466         } else if (item instanceof SourceOutput) {
467             return ITEM_SOURCE_OUTPUT;
468         }
469         return null;
470     }
471
472     /**
473      * change the volume of a {@link AbstractAudioDeviceConfig}
474      *
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)
478      */
479     public void setVolumePercent(AbstractAudioDeviceConfig item, int vol) {
480         int volumeToSet = vol;
481         if (item == null) {
482             return;
483         }
484         if (volumeToSet <= 100) {
485             volumeToSet = toAbsoluteVolume(volumeToSet);
486         }
487         setVolume(item, volumeToSet);
488     }
489
490     /**
491      * transform a percent volume to a value that can be send to the pulseaudio server (0-65536)
492      *
493      * @param percent
494      * @return
495      */
496     private int toAbsoluteVolume(int percent) {
497         return (int) Math.round(65536f / 100f * Double.valueOf(percent));
498     }
499
500     /**
501      * changes the combined sinks slaves to the given <code>sinks</code>
502      *
503      * @param combinedSink the combined sink which slaves should be changed
504      * @param sinks the list of new slaves
505      */
506     public void setCombinedSinkSlaves(Sink combinedSink, List<Sink> sinks) {
507         if (combinedSink == null || !combinedSink.isCombinedSink()) {
508             return;
509         }
510         List<String> slaves = new ArrayList<>();
511         for (Sink sink : sinks) {
512             slaves.add(sink.getPaName());
513         }
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
520         update();
521     }
522
523     /**
524      * sets the sink a sink-input should be routed to
525      *
526      * @param sinkInput the sink-input to be rerouted
527      * @param sink the new sink the sink-input should be routed to
528      */
529     public void moveSinkInput(SinkInput sinkInput, Sink sink) {
530         if (sinkInput == null || sink == null) {
531             return;
532         }
533         sendRawCommand("move-sink-input " + sinkInput.getId() + " " + sink.getId());
534         sinkInput.setSink(sink);
535     }
536
537     /**
538      * sets the sink a source-output should be routed to
539      *
540      * @param sourceOutput the source-output to be rerouted
541      * @param source the new source the source-output should be routed to
542      */
543     public void moveSourceOutput(SourceOutput sourceOutput, Source source) {
544         if (sourceOutput == null || source == null) {
545             return;
546         }
547         sendRawCommand("move-sink-input " + sourceOutput.getId() + " " + source.getId());
548         sourceOutput.setSource(source);
549     }
550
551     /**
552      * suspend a source
553      *
554      * @param source the source which state should be changed
555      * @param suspend suspend it or not
556      */
557     public void suspendSource(Source source, boolean suspend) {
558         if (source == null) {
559             return;
560         }
561         if (suspend) {
562             sendRawCommand("suspend-source " + source.getId() + " 1");
563             source.setState(State.SUSPENDED);
564         } else {
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
568             update();
569         }
570     }
571
572     /**
573      * suspend a sink
574      *
575      * @param sink the sink which state should be changed
576      * @param suspend suspend it or not
577      */
578     public void suspendSink(Sink sink, boolean suspend) {
579         if (sink == null) {
580             return;
581         }
582         if (suspend) {
583             sendRawCommand("suspend-sink " + sink.getId() + " 1");
584             sink.setState(State.SUSPENDED);
585         } else {
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
589             update();
590         }
591     }
592
593     /**
594      * changes the combined sinks slaves to the given <code>sinks</code>
595      *
596      * @param combinedSinkName the combined sink which slaves should be changed
597      * @param sinks the list of new slaves
598      */
599     public void setCombinedSinkSlaves(String combinedSinkName, List<Sink> sinks) {
600         if (getSink(combinedSinkName) != null) {
601             return;
602         }
603         List<String> slaves = new ArrayList<>();
604         for (Sink sink : sinks) {
605             slaves.add(sink.getPaName());
606         }
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
611         update();
612     }
613
614     private void sendRawCommand(String command) {
615         checkConnection();
616         if (client != null) {
617             try {
618                 PrintStream out = new PrintStream(client.getOutputStream(), true);
619                 logger.trace("sending command {} to pa-server {}", command, host);
620                 out.print(command + "\r\n");
621                 out.close();
622                 client.close();
623             } catch (IOException e) {
624                 logger.error("{}", e.getLocalizedMessage(), e);
625             }
626         }
627     }
628
629     private String sendRawRequest(String command) {
630         logger.trace("_sendRawRequest({})", command);
631         checkConnection();
632         String result = "";
633         if (client != null) {
634             try {
635                 PrintStream out = new PrintStream(client.getOutputStream(), true);
636                 out.print(command + "\r\n");
637
638                 InputStream instr = client.getInputStream();
639
640                 try {
641                     byte[] buff = new byte[1024];
642                     int retRead = 0;
643                     int lc = 0;
644                     do {
645                         retRead = instr.read(buff);
646                         lc++;
647                         if (retRead > 0) {
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);
652                                 break;
653                             }
654                             result += line.trim();
655                         }
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());
664                 }
665                 instr.close();
666                 out.close();
667                 client.close();
668                 return result;
669             } catch (IOException e) {
670                 logger.error("{}", e.getLocalizedMessage(), e);
671             }
672         }
673         return result;
674     }
675
676     private void checkConnection() {
677         if (client == null || client.isClosed() || !client.isConnected()) {
678             try {
679                 connect();
680             } catch (IOException e) {
681                 logger.error("{}", e.getLocalizedMessage(), e);
682             }
683         }
684     }
685
686     /**
687      * Connects to the pulseaudio server (timeout 500ms)
688      */
689     private void connect() throws IOException {
690         try {
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());
699         }
700     }
701
702     /**
703      * Disconnects from the pulseaudio server
704      */
705     public void disconnect() {
706         if (client != null) {
707             try {
708                 client.close();
709             } catch (IOException e) {
710                 logger.error("{}", e.getLocalizedMessage(), e);
711             }
712         }
713     }
714 }