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