]> git.basschouten.com Git - openhab-addons.git/blob
4cf99f0d4f17454a6e03a67c855d27c8ec3d5d91
[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.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.List;
27 import java.util.Optional;
28 import java.util.Random;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.pulseaudio.internal.cli.Parser;
33 import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig;
34 import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig.State;
35 import org.openhab.binding.pulseaudio.internal.items.Module;
36 import org.openhab.binding.pulseaudio.internal.items.Sink;
37 import org.openhab.binding.pulseaudio.internal.items.SinkInput;
38 import org.openhab.binding.pulseaudio.internal.items.Source;
39 import org.openhab.binding.pulseaudio.internal.items.SourceOutput;
40 import org.slf4j.Logger;
41 import org.slf4j.LoggerFactory;
42
43 /**
44  * The client connects to a pulseaudio server via TCP. It reads the current state of the
45  * pulseaudio server (available sinks, sources,...) and can send commands to the server.
46  * The syntax of the commands is the same as for the pactl command line tool provided by pulseaudio.
47  *
48  * On the pulseaudio server the module-cli-protocol-tcp has to be loaded.
49  *
50  * @author Tobias Bräutigam - Initial contribution
51  * @author Miguel Álvarez - changes for loading audio source module and nullability annotations
52  */
53 @NonNullByDefault
54 public class PulseaudioClient {
55
56     private final Logger logger = LoggerFactory.getLogger(PulseaudioClient.class);
57
58     private String host;
59     private int port;
60     private @Nullable Socket client;
61
62     private List<AbstractAudioDeviceConfig> items;
63     private List<Module> modules;
64
65     /**
66      * Corresponding to the global binding configuration
67      */
68     private PulseAudioBindingConfiguration configuration;
69
70     /**
71      * corresponding name to execute actions on sink items
72      */
73     private static final String ITEM_SINK = "sink";
74
75     /**
76      * corresponding name to execute actions on source items
77      */
78     private static final String ITEM_SOURCE = "source";
79
80     /**
81      * corresponding name to execute actions on sink-input items
82      */
83     private static final String ITEM_SINK_INPUT = "sink-input";
84
85     /**
86      * corresponding name to execute actions on source-output items
87      */
88     private static final String ITEM_SOURCE_OUTPUT = "source-output";
89
90     /**
91      * command to list the loaded modules
92      */
93     private static final String CMD_LIST_MODULES = "list-modules";
94
95     /**
96      * command to list the sinks
97      */
98     private static final String CMD_LIST_SINKS = "list-sinks";
99
100     /**
101      * command to list the sources
102      */
103     private static final String CMD_LIST_SOURCES = "list-sources";
104
105     /**
106      * command to list the sink-inputs
107      */
108     private static final String CMD_LIST_SINK_INPUTS = "list-sink-inputs";
109
110     /**
111      * command to list the source-outputs
112      */
113     private static final String CMD_LIST_SOURCE_OUTPUTS = "list-source-outputs";
114
115     /**
116      * command to load a module
117      */
118     private static final String CMD_LOAD_MODULE = "load-module";
119
120     /**
121      * command to unload a module
122      */
123     private static final String CMD_UNLOAD_MODULE = "unload-module";
124
125     /**
126      * name of the module-combine-sink
127      */
128     private static final String MODULE_COMBINE_SINK = "module-combine-sink";
129
130     public PulseaudioClient(String host, int port, PulseAudioBindingConfiguration configuration) {
131         this.host = host;
132         this.port = port;
133         this.configuration = configuration;
134
135         items = new ArrayList<>();
136         modules = new ArrayList<>();
137     }
138
139     public boolean isConnected() {
140         Socket clientSocket = client;
141         return clientSocket != null ? clientSocket.isConnected() : false;
142     }
143
144     /**
145      * updates the item states and their relationships
146      */
147     public synchronized void update() {
148         // one step copy
149         modules = new ArrayList<Module>(Parser.parseModules(listModules()));
150
151         List<AbstractAudioDeviceConfig> newItems = new ArrayList<>(); // prepare new list before assigning it
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 @Nullable 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 @Nullable 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 @Nullable 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 Source} by its id
248      *
249      * @return the corresponding {@link Source} to the given <code>id</code>
250      */
251     public @Nullable Source getSource(int id) {
252         for (AbstractAudioDeviceConfig item : items) {
253             if (item.getId() == id && item instanceof Source) {
254                 return (Source) item;
255             }
256         }
257         return null;
258     }
259
260     /**
261      * retrieves a {@link AbstractAudioDeviceConfig} by its name
262      *
263      * @return the corresponding {@link AbstractAudioDeviceConfig} to the given <code>name</code>
264      */
265     public @Nullable AbstractAudioDeviceConfig getGenericAudioItem(String name) {
266         for (AbstractAudioDeviceConfig item : items) {
267             if (item.getPaName().equalsIgnoreCase(name)) {
268                 return item;
269             }
270         }
271         return null;
272     }
273
274     /**
275      * Get all items previously parsed from the pulseaudio server.
276      *
277      * @return All items parsed from the pulseaudio server
278      */
279     public List<AbstractAudioDeviceConfig> getItems() {
280         return items;
281     }
282
283     /**
284      * changes the <code>mute</code> state of the corresponding {@link Sink}
285      *
286      * @param item the {@link Sink} to handle
287      * @param mute mutes the sink if true, unmutes if false
288      */
289     public void setMute(@Nullable AbstractAudioDeviceConfig item, boolean mute) {
290         if (item == null) {
291             return;
292         }
293         String itemCommandName = getItemCommandName(item);
294         if (itemCommandName == null) {
295             return;
296         }
297         String muteString = mute ? "1" : "0";
298         sendRawCommand("set-" + itemCommandName + "-mute " + item.getId() + " " + muteString);
299         // update internal data
300         item.setMuted(mute);
301     }
302
303     /**
304      * change the volume of a {@link AbstractAudioDeviceConfig}
305      *
306      * @param item the {@link AbstractAudioDeviceConfig} to handle
307      * @param vol the new volume value the {@link AbstractAudioDeviceConfig} should be changed to (possible values from
308      *            0 - 65536)
309      */
310     public void setVolume(AbstractAudioDeviceConfig item, int vol) {
311         String itemCommandName = getItemCommandName(item);
312         if (itemCommandName == null) {
313             return;
314         }
315         sendRawCommand("set-" + itemCommandName + "-volume " + item.getId() + " " + vol);
316         item.setVolume(Math.round(100f / 65536f * vol));
317     }
318
319     /**
320      * Locate or load (if needed) the simple protocol tcp module for the given sink
321      * and returns the port.
322      * The module loading (if needed) will be tried several times, on a new random port each time.
323      *
324      * @param item the sink we are searching for
325      * @param simpleTcpPortPref the port to use if we have to load the module
326      * @return the port on which the module is listening
327      * @throws InterruptedException
328      */
329     public Optional<Integer> loadModuleSimpleProtocolTcpIfNeeded(AbstractAudioDeviceConfig item,
330             Integer simpleTcpPortPref, @Nullable String format, @Nullable BigDecimal rate,
331             @Nullable BigDecimal channels) throws InterruptedException {
332         int currentTry = 0;
333         int simpleTcpPortToTry = simpleTcpPortPref;
334         String itemType = getItemCommandName(item);
335         do {
336             Optional<Integer> simplePort = findSimpleProtocolTcpModule(item, format, rate, channels);
337
338             if (simplePort.isPresent()) {
339                 return simplePort;
340             } else {
341                 String moduleOptions = itemType + "=" + item.getPaName() + " port=" + simpleTcpPortToTry;
342                 if (item instanceof Source && format != null && rate != null && channels != null) {
343                     moduleOptions = moduleOptions + String.format(" record=true format=%s rate=%d channels=%d", format,
344                             rate.longValue(), channels.intValue());
345                 }
346                 sendRawCommand("load-module module-simple-protocol-tcp " + moduleOptions);
347                 simpleTcpPortToTry = new Random().nextInt(64512) + 1024; // a random port above 1024
348             }
349             Thread.sleep(100);
350             update();
351             currentTry++;
352         } while (currentTry < 3);
353
354         logger.warn("The pulseaudio binding tried 3 times to load the module-simple-protocol-tcp"
355                 + " on random port on the pulseaudio server and give up trying");
356         return Optional.empty();
357     }
358
359     /**
360      * Find a simple protocol module corresponding to the given sink in argument
361      * and returns the port it listens to
362      *
363      * @param item
364      * @return
365      */
366     private Optional<Integer> findSimpleProtocolTcpModule(AbstractAudioDeviceConfig item, @Nullable String format,
367             @Nullable BigDecimal rate, @Nullable BigDecimal channels) {
368         String itemType = getItemCommandName(item);
369         if (itemType == null) {
370             return Optional.empty();
371         }
372         List<Module> modulesCopy = new ArrayList<Module>(modules);
373         var isSource = item instanceof Source;
374         return modulesCopy.stream() // iteration on modules
375                 .filter(module -> MODULE_SIMPLE_PROTOCOL_TCP_NAME.equals(module.getPaName())) // filter on module name
376                 .filter(module -> {
377                     boolean nameMatch = extractArgumentFromLine(itemType, module.getArgument()) // extract sick|source
378                             .map(name -> name.equals(item.getPaName())).orElse(false);
379                     if (isSource && nameMatch) {
380                         boolean recordStream = extractArgumentFromLine("record", module.getArgument())
381                                 .map("true"::equals).orElse(false);
382                         if (!recordStream) {
383                             return false;
384                         }
385                         if (format != null) {
386                             boolean rateMatch = extractArgumentFromLine("format", module.getArgument())
387                                     .map(format::equals).orElse(false);
388                             if (!rateMatch) {
389                                 return false;
390                             }
391                         }
392                         if (rate != null) {
393                             boolean rateMatch = extractArgumentFromLine("rate", module.getArgument())
394                                     .map(value -> Long.parseLong(value) == rate.longValue()).orElse(false);
395                             if (!rateMatch) {
396                                 return false;
397                             }
398                         }
399                         if (channels != null) {
400                             boolean channelsMatch = extractArgumentFromLine("channels", module.getArgument())
401                                     .map(value -> Integer.parseInt(value) == channels.intValue()).orElse(false);
402                             if (!channelsMatch) {
403                                 return false;
404                             }
405                         }
406                     }
407                     return nameMatch;
408                 }) // filter on sink name
409                 .findAny() // get a corresponding module
410                 .map(module -> extractArgumentFromLine("port", module.getArgument())
411                         .orElse(Integer.toString(MODULE_SIMPLE_PROTOCOL_TCP_DEFAULT_PORT))) // get port
412                 .map(portS -> Integer.parseInt(portS));
413     }
414
415     private Optional<String> extractArgumentFromLine(String argumentWanted, @Nullable String argumentLine) {
416         String argument = null;
417         if (argumentLine != null) {
418             int startPortIndex = argumentLine.indexOf(argumentWanted + "=");
419             if (startPortIndex != -1) {
420                 startPortIndex = startPortIndex + argumentWanted.length() + 1;
421                 int endPortIndex = argumentLine.indexOf(" ", startPortIndex);
422                 if (endPortIndex == -1) {
423                     endPortIndex = argumentLine.length();
424                 }
425                 argument = argumentLine.substring(startPortIndex, endPortIndex);
426             }
427         }
428         return Optional.ofNullable(argument);
429     }
430
431     /**
432      * returns the item names that can be used in commands
433      *
434      * @param item
435      * @return
436      */
437     private @Nullable String getItemCommandName(AbstractAudioDeviceConfig item) {
438         if (item instanceof Sink) {
439             return ITEM_SINK;
440         } else if (item instanceof Source) {
441             return ITEM_SOURCE;
442         } else if (item instanceof SinkInput) {
443             return ITEM_SINK_INPUT;
444         } else if (item instanceof SourceOutput) {
445             return ITEM_SOURCE_OUTPUT;
446         }
447         return null;
448     }
449
450     /**
451      * change the volume of a {@link AbstractAudioDeviceConfig}
452      *
453      * @param item the {@link AbstractAudioDeviceConfig} to handle
454      * @param vol the new volume percent value the {@link AbstractAudioDeviceConfig} should be changed to (possible
455      *            values from 0 - 100)
456      */
457     public void setVolumePercent(AbstractAudioDeviceConfig item, int vol) {
458         int volumeToSet = vol;
459         if (volumeToSet <= 100) {
460             volumeToSet = toAbsoluteVolume(volumeToSet);
461         }
462         setVolume(item, volumeToSet);
463     }
464
465     /**
466      * transform a percent volume to a value that can be send to the pulseaudio server (0-65536)
467      *
468      * @param percent
469      * @return
470      */
471     private int toAbsoluteVolume(int percent) {
472         return (int) Math.round(65536f / 100f * Double.valueOf(percent));
473     }
474
475     /**
476      * changes the combined sinks slaves to the given <code>sinks</code>
477      *
478      * @param combinedSink the combined sink which slaves should be changed
479      * @param sinks the list of new slaves
480      */
481     public void setCombinedSinkSlaves(@Nullable Sink combinedSink, List<Sink> sinks) {
482         if (combinedSink == null || !combinedSink.isCombinedSink()) {
483             return;
484         }
485         List<String> slaves = new ArrayList<>();
486         for (Sink sink : sinks) {
487             slaves.add(sink.getPaName());
488         }
489         // 1. delete old combined-sink
490         Module lastModule = combinedSink.getModule();
491         if (lastModule != null) {
492             sendRawCommand(CMD_UNLOAD_MODULE + " " + lastModule.getId());
493         }
494         // 2. add new combined-sink with same name and all slaves
495         sendRawCommand(CMD_LOAD_MODULE + " " + MODULE_COMBINE_SINK + " sink_name=" + combinedSink.getPaName()
496                 + " slaves=" + String.join(",", slaves));
497         // 3. update internal data structure because the combined sink has a new number + other slaves
498         update();
499     }
500
501     /**
502      * sets the sink a sink-input should be routed to
503      *
504      * @param sinkInput the sink-input to be rerouted
505      * @param sink the new sink the sink-input should be routed to
506      */
507     public void moveSinkInput(@Nullable SinkInput sinkInput, @Nullable Sink sink) {
508         if (sinkInput == null || sink == null) {
509             return;
510         }
511         sendRawCommand("move-sink-input " + sinkInput.getId() + " " + sink.getId());
512         sinkInput.setSink(sink);
513     }
514
515     /**
516      * sets the sink a source-output should be routed to
517      *
518      * @param sourceOutput the source-output to be rerouted
519      * @param source the new source the source-output should be routed to
520      */
521     public void moveSourceOutput(@Nullable SourceOutput sourceOutput, @Nullable Source source) {
522         if (sourceOutput == null || source == null) {
523             return;
524         }
525         sendRawCommand("move-sink-input " + sourceOutput.getId() + " " + source.getId());
526         sourceOutput.setSource(source);
527     }
528
529     /**
530      * suspend a source
531      *
532      * @param source the source which state should be changed
533      * @param suspend suspend it or not
534      */
535     public void suspendSource(@Nullable Source source, boolean suspend) {
536         if (source == null) {
537             return;
538         }
539         if (suspend) {
540             sendRawCommand("suspend-source " + source.getId() + " 1");
541             source.setState(State.SUSPENDED);
542         } else {
543             sendRawCommand("suspend-source " + source.getId() + " 0");
544             // unsuspending the source could result in different states (RUNNING,IDLE,...)
545             // update to get the new state
546             update();
547         }
548     }
549
550     /**
551      * suspend a sink
552      *
553      * @param sink the sink which state should be changed
554      * @param suspend suspend it or not
555      */
556     public void suspendSink(@Nullable Sink sink, boolean suspend) {
557         if (sink == null) {
558             return;
559         }
560         if (suspend) {
561             sendRawCommand("suspend-sink " + sink.getId() + " 1");
562             sink.setState(State.SUSPENDED);
563         } else {
564             sendRawCommand("suspend-sink " + sink.getId() + " 0");
565             // unsuspending the sink could result in different states (RUNNING,IDLE,...)
566             // update to get the new state
567             update();
568         }
569     }
570
571     /**
572      * changes the combined sinks slaves to the given <code>sinks</code>
573      *
574      * @param combinedSinkName the combined sink which slaves should be changed
575      * @param sinks the list of new slaves
576      */
577     public void setCombinedSinkSlaves(String combinedSinkName, List<Sink> sinks) {
578         if (getSink(combinedSinkName) != null) {
579             return;
580         }
581         List<String> slaves = new ArrayList<>();
582         for (Sink sink : sinks) {
583             slaves.add(sink.getPaName());
584         }
585         // add new combined-sink with same name and all slaves
586         sendRawCommand(CMD_LOAD_MODULE + " " + MODULE_COMBINE_SINK + " sink_name=" + combinedSinkName + " slaves="
587                 + String.join(",", slaves));
588         // update internal data structure because the combined sink is new
589         update();
590     }
591
592     private synchronized void sendRawCommand(String command) {
593         checkConnection();
594         Socket clientSocket = client;
595         if (clientSocket != null && clientSocket.isConnected()) {
596             try {
597                 PrintStream out = new PrintStream(clientSocket.getOutputStream(), true);
598                 logger.trace("sending command {} to pa-server {}", command, host);
599                 out.print(command + "\r\n");
600                 out.close();
601                 clientSocket.close();
602             } catch (IOException e) {
603                 logger.warn("{}", e.getMessage(), e);
604             }
605         }
606     }
607
608     private String sendRawRequest(String command) {
609         logger.trace("_sendRawRequest({})", command);
610         checkConnection();
611         String result = "";
612         Socket clientSocket = client;
613         if (clientSocket != null && clientSocket.isConnected()) {
614             try {
615                 PrintStream out = new PrintStream(clientSocket.getOutputStream(), true);
616                 out.print(command + "\r\n");
617
618                 InputStream instr = clientSocket.getInputStream();
619
620                 try {
621                     byte[] buff = new byte[1024];
622                     int retRead = 0;
623                     int lc = 0;
624                     do {
625                         retRead = instr.read(buff);
626                         lc++;
627                         if (retRead > 0) {
628                             String line = new String(buff, 0, retRead);
629                             // System.out.println("'"+line+"'");
630                             if (line.endsWith(">>> ") && lc > 1) {
631                                 result += line.substring(0, line.length() - 4);
632                                 break;
633                             }
634                             result += line.trim();
635                         }
636                     } while (retRead > 0);
637                 } catch (SocketTimeoutException e) {
638                     // Timeout -> as newer PA versions (>=5.0) do not send the >>> we have no chance
639                     // to detect the end of the answer, except by this timeout
640                 } catch (SocketException e) {
641                     logger.warn("Socket exception while sending pulseaudio command: {}", e.getMessage());
642                 } catch (IOException e) {
643                     logger.warn("Exception while reading socket: {}", e.getMessage());
644                 }
645                 instr.close();
646                 out.close();
647                 clientSocket.close();
648                 return result;
649             } catch (IOException e) {
650                 logger.warn("{}", e.getMessage(), e);
651             }
652         }
653         return result;
654     }
655
656     private void checkConnection() {
657         try {
658             connect();
659         } catch (IOException e) {
660             logger.debug("{}", e.getMessage(), e);
661         }
662     }
663
664     /**
665      * Connects to the pulseaudio server (timeout 500ms)
666      */
667     public void connect() throws IOException {
668         Socket clientSocket = client;
669         if (clientSocket == null || clientSocket.isClosed() || !clientSocket.isConnected()) {
670             logger.trace("Try to connect...");
671             try {
672                 var clientFinal = new Socket(host, port);
673                 clientFinal.setSoTimeout(500);
674                 client = clientFinal;
675                 logger.trace("connected");
676             } catch (UnknownHostException e) {
677                 client = null;
678                 throw new IOException("Unknown host", e);
679             } catch (IllegalArgumentException e) {
680                 client = null;
681                 throw new IOException("Invalid port", e);
682             } catch (SecurityException | SocketException e) {
683                 client = null;
684                 throw new IOException(
685                         String.format("Cannot connect socket: %s", e.getMessage() != null ? e.getMessage() : ""), e);
686             } catch (IOException e) {
687                 client = null;
688                 throw e;
689             }
690         }
691     }
692
693     /**
694      * Disconnects from the pulseaudio server
695      */
696     public void disconnect() {
697         Socket clientSocket = client;
698         if (clientSocket != null) {
699             try {
700                 clientSocket.close();
701             } catch (IOException e) {
702                 logger.debug("{}", e.getMessage(), e);
703             }
704         }
705     }
706 }