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