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