]> git.basschouten.com Git - openhab-addons.git/blob
b6821bd5ffa394a94f749fc59c7317f53a327544
[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.handler;
14
15 import static org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants.*;
16
17 import java.io.IOException;
18 import java.math.BigDecimal;
19 import java.util.ArrayList;
20 import java.util.Collections;
21 import java.util.Hashtable;
22 import java.util.List;
23 import java.util.Map;
24 import java.util.Optional;
25 import java.util.Set;
26 import java.util.concurrent.ConcurrentHashMap;
27 import java.util.stream.Collectors;
28 import java.util.stream.Stream;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.pulseaudio.internal.PulseAudioAudioSink;
33 import org.openhab.binding.pulseaudio.internal.PulseAudioAudioSource;
34 import org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants;
35 import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig;
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.core.audio.AudioFormat;
40 import org.openhab.core.audio.AudioSink;
41 import org.openhab.core.audio.AudioSource;
42 import org.openhab.core.config.core.Configuration;
43 import org.openhab.core.library.types.DecimalType;
44 import org.openhab.core.library.types.IncreaseDecreaseType;
45 import org.openhab.core.library.types.OnOffType;
46 import org.openhab.core.library.types.PercentType;
47 import org.openhab.core.library.types.StringType;
48 import org.openhab.core.thing.Bridge;
49 import org.openhab.core.thing.ChannelUID;
50 import org.openhab.core.thing.Thing;
51 import org.openhab.core.thing.ThingStatus;
52 import org.openhab.core.thing.ThingStatusDetail;
53 import org.openhab.core.thing.ThingStatusInfo;
54 import org.openhab.core.thing.ThingTypeUID;
55 import org.openhab.core.thing.binding.BaseThingHandler;
56 import org.openhab.core.thing.binding.ThingHandler;
57 import org.openhab.core.types.Command;
58 import org.openhab.core.types.RefreshType;
59 import org.openhab.core.types.State;
60 import org.openhab.core.types.UnDefType;
61 import org.osgi.framework.BundleContext;
62 import org.osgi.framework.ServiceRegistration;
63 import org.slf4j.Logger;
64 import org.slf4j.LoggerFactory;
65
66 /**
67  * The {@link PulseaudioHandler} is responsible for handling commands, which are
68  * sent to one of the channels.
69  *
70  * @author Tobias Bräutigam - Initial contribution
71  * @author Miguel Álvarez - Register audio source and refactor
72  */
73 @NonNullByDefault
74 public class PulseaudioHandler extends BaseThingHandler {
75
76     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
77             .unmodifiableSet(Stream.of(SINK_THING_TYPE, COMBINED_SINK_THING_TYPE, SINK_INPUT_THING_TYPE,
78                     SOURCE_THING_TYPE, SOURCE_OUTPUT_THING_TYPE).collect(Collectors.toSet()));
79     private final Logger logger = LoggerFactory.getLogger(PulseaudioHandler.class);
80
81     private String name = "";
82     private @Nullable PulseAudioAudioSink audioSink;
83     private @Nullable PulseAudioAudioSource audioSource;
84     private @Nullable Integer savedVolume;
85
86     private final Map<String, ServiceRegistration<AudioSink>> audioSinkRegistrations = new ConcurrentHashMap<>();
87     private final Map<String, ServiceRegistration<AudioSource>> audioSourceRegistrations = new ConcurrentHashMap<>();
88
89     private final BundleContext bundleContext;
90
91     public PulseaudioHandler(Thing thing, BundleContext bundleContext) {
92         super(thing);
93         this.bundleContext = bundleContext;
94     }
95
96     @Override
97     public void initialize() {
98         Configuration config = getThing().getConfiguration();
99         name = (String) config.get(DEVICE_PARAMETER_NAME);
100         initializeWithTheBridge();
101     }
102
103     public String getName() {
104         return name;
105     }
106
107     private void audioSinkSetup() {
108         if (audioSink != null) {
109             // Audio sink is already setup
110             return;
111         }
112         if (!SINK_THING_TYPE.equals(thing.getThingTypeUID())) {
113             return;
114         }
115         // check the property to see if it's enabled :
116         Boolean sinkActivated = (Boolean) thing.getConfiguration().get(DEVICE_PARAMETER_AUDIO_SINK_ACTIVATION);
117         if (sinkActivated == null || !sinkActivated.booleanValue()) {
118             return;
119         }
120         final PulseaudioHandler thisHandler = this;
121         PulseAudioAudioSink audioSink = new PulseAudioAudioSink(thisHandler, scheduler);
122         scheduler.submit(new Runnable() {
123             @Override
124             public void run() {
125                 PulseaudioHandler.this.audioSink = audioSink;
126                 try {
127                     audioSink.connectIfNeeded();
128                 } catch (IOException e) {
129                     logger.warn("pulseaudio binding cannot connect to the module-simple-protocol-tcp on {} ({})",
130                             getHost(), e.getMessage());
131                 } catch (InterruptedException i) {
132                     logger.info("Interrupted during sink audio connection: {}", i.getMessage());
133                     return;
134                 } finally {
135                     audioSink.scheduleDisconnect();
136                 }
137             }
138         });
139         // Register the sink as an audio sink in openhab
140         logger.trace("Registering an audio sink for pulse audio sink thing {}", thing.getUID());
141         @SuppressWarnings("unchecked")
142         ServiceRegistration<AudioSink> reg = (ServiceRegistration<AudioSink>) bundleContext
143                 .registerService(AudioSink.class.getName(), audioSink, new Hashtable<>());
144         audioSinkRegistrations.put(thing.getUID().toString(), reg);
145     }
146
147     private void audioSinkUnsetup() {
148         PulseAudioAudioSink sink = audioSink;
149         if (sink != null) {
150             sink.disconnect();
151             audioSink = null;
152         }
153         // Unregister the potential pulse audio sink's audio sink
154         ServiceRegistration<AudioSink> sinkReg = audioSinkRegistrations.remove(getThing().getUID().toString());
155         if (sinkReg != null) {
156             logger.trace("Unregistering the audio sync service for pulse audio sink thing {}", getThing().getUID());
157             sinkReg.unregister();
158         }
159     }
160
161     private void audioSourceSetup() {
162         if (audioSource != null) {
163             // Audio source is already setup
164             return;
165         }
166         if (!SOURCE_THING_TYPE.equals(thing.getThingTypeUID())) {
167             return;
168         }
169         // check the property to see if it's enabled :
170         Boolean sourceActivated = (Boolean) thing.getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_ACTIVATION);
171         if (sourceActivated == null || !sourceActivated.booleanValue()) {
172             return;
173         }
174         final PulseaudioHandler thisHandler = this;
175         PulseAudioAudioSource audioSource = new PulseAudioAudioSource(thisHandler, scheduler);
176         scheduler.submit(new Runnable() {
177             @Override
178             public void run() {
179                 PulseaudioHandler.this.audioSource = audioSource;
180                 try {
181                     audioSource.connectIfNeeded();
182                 } catch (IOException e) {
183                     logger.warn("pulseaudio binding cannot connect to the module-simple-protocol-tcp on {} ({})",
184                             getHost(), e.getMessage());
185                 } catch (InterruptedException i) {
186                     logger.info("Interrupted during source audio connection: {}", i.getMessage());
187                     return;
188                 } finally {
189                     audioSource.scheduleDisconnect();
190                 }
191             }
192         });
193         // Register the source as an audio source in openhab
194         logger.trace("Registering an audio source for pulse audio source thing {}", thing.getUID());
195         @SuppressWarnings("unchecked")
196         ServiceRegistration<AudioSource> reg = (ServiceRegistration<AudioSource>) bundleContext
197                 .registerService(AudioSource.class.getName(), audioSource, new Hashtable<>());
198         audioSourceRegistrations.put(thing.getUID().toString(), reg);
199     }
200
201     private void audioSourceUnsetup() {
202         PulseAudioAudioSource source = audioSource;
203         if (source != null) {
204             source.disconnect();
205             audioSource = null;
206         }
207         // Unregister the potential pulse audio source's audio sources
208         ServiceRegistration<AudioSource> sourceReg = audioSourceRegistrations.remove(getThing().getUID().toString());
209         if (sourceReg != null) {
210             logger.trace("Unregistering the audio sync service for pulse audio source thing {}", getThing().getUID());
211             sourceReg.unregister();
212         }
213     }
214
215     @Override
216     public void dispose() {
217         logger.trace("Thing {} {} disposed.", getThing().getUID(), name);
218         super.dispose();
219         audioSinkUnsetup();
220         audioSourceUnsetup();
221     }
222
223     @Override
224     public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
225         initializeWithTheBridge();
226     }
227
228     private void initializeWithTheBridge() {
229         PulseaudioBridgeHandler pulseaudioBridgeHandler = getPulseaudioBridgeHandler();
230         if (pulseaudioBridgeHandler == null) {
231             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
232         } else if (pulseaudioBridgeHandler.getThing().getStatus() != ThingStatus.ONLINE) {
233             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
234         } else {
235             deviceUpdate(pulseaudioBridgeHandler.getDevice(name));
236         }
237     }
238
239     private synchronized @Nullable PulseaudioBridgeHandler getPulseaudioBridgeHandler() {
240         Bridge bridge = getBridge();
241         if (bridge == null) {
242             logger.debug("Required bridge not defined for device {}.", name);
243             return null;
244         }
245         ThingHandler handler = bridge.getHandler();
246         if (handler instanceof PulseaudioBridgeHandler) {
247             return (PulseaudioBridgeHandler) handler;
248         } else {
249             logger.debug("No available bridge handler found for device {} bridge {} .", name, bridge.getUID());
250             return null;
251         }
252     }
253
254     @Override
255     public void handleCommand(ChannelUID channelUID, Command command) {
256         PulseaudioBridgeHandler briHandler = getPulseaudioBridgeHandler();
257         if (briHandler == null) {
258             logger.debug("pulseaudio server bridge handler not found. Cannot handle command without bridge.");
259             return;
260         }
261         if (command instanceof RefreshType) {
262             briHandler.handleCommand(channelUID, command);
263             return;
264         }
265
266         AbstractAudioDeviceConfig device = briHandler.getDevice(name);
267         if (device == null) {
268             logger.warn("device {} not found", name);
269             deviceUpdate(null);
270             return;
271         } else {
272             State updateState = UnDefType.UNDEF;
273             if (channelUID.getId().equals(VOLUME_CHANNEL)) {
274                 if (command instanceof IncreaseDecreaseType) {
275                     // refresh to get the current volume level
276                     briHandler.getClient().update();
277                     device = briHandler.getDevice(name);
278                     if (device == null) {
279                         logger.warn("missing device info, aborting");
280                         return;
281                     }
282                     int oldVolume = device.getVolume();
283                     int newVolume = oldVolume;
284                     if (command.equals(IncreaseDecreaseType.INCREASE)) {
285                         newVolume = Math.min(100, oldVolume + 5);
286                     }
287                     if (command.equals(IncreaseDecreaseType.DECREASE)) {
288                         newVolume = Math.max(0, oldVolume - 5);
289                     }
290                     briHandler.getClient().setVolumePercent(device, newVolume);
291                     updateState = new PercentType(newVolume);
292                     savedVolume = newVolume;
293                 } else if (command instanceof PercentType) {
294                     DecimalType volume = (DecimalType) command;
295                     briHandler.getClient().setVolumePercent(device, volume.intValue());
296                     updateState = (PercentType) command;
297                     savedVolume = volume.intValue();
298                 } else if (command instanceof DecimalType) {
299                     // set volume
300                     DecimalType volume = (DecimalType) command;
301                     briHandler.getClient().setVolume(device, volume.intValue());
302                     updateState = (DecimalType) command;
303                     savedVolume = volume.intValue();
304                 }
305             } else if (channelUID.getId().equals(MUTE_CHANNEL)) {
306                 if (command instanceof OnOffType) {
307                     briHandler.getClient().setMute(device, OnOffType.ON.equals(command));
308                     updateState = (OnOffType) command;
309                 }
310             } else if (channelUID.getId().equals(SLAVES_CHANNEL)) {
311                 if (device instanceof Sink && ((Sink) device).isCombinedSink()) {
312                     if (command instanceof StringType) {
313                         List<Sink> slaves = new ArrayList<>();
314                         for (String slaveName : command.toString().split(",")) {
315                             Sink slave = briHandler.getClient().getSink(slaveName.trim());
316                             if (slave != null) {
317                                 slaves.add(slave);
318                             }
319                         }
320                         if (!slaves.isEmpty()) {
321                             briHandler.getClient().setCombinedSinkSlaves(((Sink) device), slaves);
322                         }
323                     }
324                 } else {
325                     logger.warn("{} is no combined sink", device);
326                 }
327             } else if (channelUID.getId().equals(ROUTE_TO_SINK_CHANNEL)) {
328                 if (device instanceof SinkInput) {
329                     Sink newSink = null;
330                     if (command instanceof DecimalType) {
331                         newSink = briHandler.getClient().getSink(((DecimalType) command).intValue());
332                     } else {
333                         newSink = briHandler.getClient().getSink(command.toString());
334                     }
335                     if (newSink != null) {
336                         logger.debug("rerouting {} to {}", device, newSink);
337                         briHandler.getClient().moveSinkInput(((SinkInput) device), newSink);
338                         updateState = new StringType(newSink.getPaName());
339                     } else {
340                         logger.warn("no sink {} found", command.toString());
341                     }
342                 }
343             }
344             logger.trace("updating {} to {}", channelUID, updateState);
345             if (!updateState.equals(UnDefType.UNDEF)) {
346                 updateState(channelUID, updateState);
347             }
348         }
349     }
350
351     /**
352      * Use last checked volume for faster access
353      *
354      * @return
355      */
356     public Integer getLastVolume() {
357         Integer savedVolumeFinal = savedVolume;
358         if (savedVolumeFinal == null) {
359             PulseaudioBridgeHandler briHandler = getPulseaudioBridgeHandler();
360             if (briHandler != null) {
361                 // refresh to get the current volume level
362                 briHandler.getClient().update();
363                 AbstractAudioDeviceConfig device = briHandler.getDevice(name);
364                 if (device != null) {
365                     savedVolume = savedVolumeFinal = device.getVolume();
366                 }
367             }
368         }
369         return savedVolumeFinal == null ? 50 : savedVolumeFinal;
370     }
371
372     public void setVolume(int volume) {
373         PulseaudioBridgeHandler briHandler = getPulseaudioBridgeHandler();
374         if (briHandler == null) {
375             logger.warn("bridge is not ready");
376             return;
377         }
378         AbstractAudioDeviceConfig device = briHandler.getDevice(name);
379         if (device == null) {
380             logger.warn("missing device info, aborting");
381             return;
382         }
383         briHandler.getClient().setVolumePercent(device, volume);
384         updateState(VOLUME_CHANNEL, new PercentType(volume));
385         savedVolume = volume;
386     }
387
388     public void deviceUpdate(@Nullable AbstractAudioDeviceConfig device) {
389         if (device != null && device.getPaName().equals(name)) {
390             updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
391             logger.debug("Updating states of {} id: {}", device, VOLUME_CHANNEL);
392             int actualVolume = device.getVolume();
393             savedVolume = actualVolume;
394             updateState(VOLUME_CHANNEL, new PercentType(actualVolume));
395             updateState(MUTE_CHANNEL, OnOffType.from(device.isMuted()));
396             org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig.State state = device.getState();
397             updateState(STATE_CHANNEL, state != null ? new StringType(state.toString()) : new StringType("-"));
398             if (device instanceof SinkInput) {
399                 updateState(ROUTE_TO_SINK_CHANNEL, new StringType(
400                         Optional.ofNullable(((SinkInput) device).getSink()).map(Sink::getPaName).orElse("-")));
401             }
402             if (device instanceof Sink && ((Sink) device).isCombinedSink()) {
403                 updateState(SLAVES_CHANNEL, new StringType(String.join(",", ((Sink) device).getCombinedSinkNames())));
404             }
405             audioSinkSetup();
406             audioSourceSetup();
407         } else if (device == null) {
408             updateState(VOLUME_CHANNEL, UnDefType.UNDEF);
409             updateState(MUTE_CHANNEL, UnDefType.UNDEF);
410             updateState(STATE_CHANNEL, UnDefType.UNDEF);
411             if (SINK_INPUT_THING_TYPE.equals(thing.getThingTypeUID())) {
412                 updateState(ROUTE_TO_SINK_CHANNEL, UnDefType.UNDEF);
413             }
414             if (COMBINED_SINK_THING_TYPE.equals(thing.getThingTypeUID())) {
415                 updateState(SLAVES_CHANNEL, UnDefType.UNDEF);
416             }
417             audioSinkUnsetup();
418             audioSourceUnsetup();
419             updateStatus(ThingStatus.OFFLINE);
420         }
421     }
422
423     public String getHost() {
424         Bridge bridge = getBridge();
425         if (bridge != null) {
426             return (String) bridge.getConfiguration().get(PulseaudioBindingConstants.BRIDGE_PARAMETER_HOST);
427         } else {
428             logger.warn("A bridge must be configured for this pulseaudio thing");
429             return "null";
430         }
431     }
432
433     /**
434      * This method will scan the pulseaudio server to find the port on which the module/sink/source is listening
435      * If no module is listening, then it will command the module to load on the pulse audio server,
436      *
437      * @return the port on which the pulseaudio server is listening for this sink/source
438      * @throws IOException when device info is not available
439      * @throws InterruptedException when interrupted during the loading module wait
440      */
441     public int getSimpleTcpPortAndLoadModuleIfNecessary() throws IOException, InterruptedException {
442         var briHandler = getPulseaudioBridgeHandler();
443         if (briHandler == null) {
444             throw new IOException("bridge is not ready");
445         }
446         AbstractAudioDeviceConfig device = briHandler.getDevice(name);
447         if (device == null) {
448             throw new IOException("missing device info, device appears to be offline");
449         }
450         String simpleTcpPortPrefName = (device instanceof Source) ? DEVICE_PARAMETER_AUDIO_SOURCE_PORT
451                 : DEVICE_PARAMETER_AUDIO_SINK_PORT;
452         BigDecimal simpleTcpPortPref = ((BigDecimal) getThing().getConfiguration().get(simpleTcpPortPrefName));
453         int simpleTcpPort = simpleTcpPortPref != null ? simpleTcpPortPref.intValue()
454                 : MODULE_SIMPLE_PROTOCOL_TCP_DEFAULT_PORT;
455         String simpleFormat = ((String) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_FORMAT));
456         BigDecimal simpleRate = (BigDecimal) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_RATE);
457         BigDecimal simpleChannels = (BigDecimal) getThing().getConfiguration()
458                 .get(DEVICE_PARAMETER_AUDIO_SOURCE_CHANNELS);
459         return briHandler.getClient()
460                 .loadModuleSimpleProtocolTcpIfNeeded(device, simpleTcpPort, simpleFormat, simpleRate, simpleChannels)
461                 .orElse(simpleTcpPort);
462     }
463
464     public @Nullable AudioFormat getSourceAudioFormat() {
465         String simpleFormat = ((String) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_FORMAT));
466         BigDecimal simpleRate = ((BigDecimal) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_RATE));
467         BigDecimal simpleChannels = ((BigDecimal) getThing().getConfiguration()
468                 .get(DEVICE_PARAMETER_AUDIO_SOURCE_CHANNELS));
469         if (simpleFormat == null || simpleRate == null || simpleChannels == null) {
470             return null;
471         }
472         switch (simpleFormat) {
473             case "u8":
474                 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, null, 8, 1,
475                         simpleRate.longValue(), simpleChannels.intValue());
476             case "s16le":
477                 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false, 16, 1,
478                         simpleRate.longValue(), simpleChannels.intValue());
479             case "s16be":
480                 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, true, 16, 1,
481                         simpleRate.longValue(), simpleChannels.intValue());
482             case "s24le":
483                 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, false, 24, 1,
484                         simpleRate.longValue(), simpleChannels.intValue());
485             case "s24be":
486                 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, true, 24, 1,
487                         simpleRate.longValue(), simpleChannels.intValue());
488             case "s32le":
489                 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, false, 32, 1,
490                         simpleRate.longValue(), simpleChannels.intValue());
491             case "s32be":
492                 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, true, 32, 1,
493                         simpleRate.longValue(), simpleChannels.intValue());
494             default:
495                 logger.warn("unsupported format {}", simpleFormat);
496                 return null;
497         }
498     }
499
500     public int getIdleTimeout() {
501         var idleTimeout = 3000;
502         var handler = getPulseaudioBridgeHandler();
503         if (handler != null) {
504             AbstractAudioDeviceConfig device = handler.getDevice(name);
505             String idleTimeoutPropName = (device instanceof Source) ? DEVICE_PARAMETER_AUDIO_SOURCE_IDLE_TIMEOUT
506                     : DEVICE_PARAMETER_AUDIO_SINK_IDLE_TIMEOUT;
507             var idleTimeoutB = (BigDecimal) getThing().getConfiguration().get(idleTimeoutPropName);
508             if (idleTimeoutB != null) {
509                 idleTimeout = idleTimeoutB.intValue();
510             }
511         }
512         return idleTimeout;
513     }
514
515     public int getBasicProtocolSOTimeout() {
516         var soTimeout = (BigDecimal) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOCKET_SO_TIMEOUT);
517         return soTimeout != null ? soTimeout.intValue() : 500;
518     }
519 }