2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.pulseaudio.internal.handler;
15 import static org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants.*;
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;
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;
31 import org.openhab.binding.pulseaudio.internal.PulseAudioAudioSink;
32 import org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants;
33 import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig;
34 import org.openhab.binding.pulseaudio.internal.items.Sink;
35 import org.openhab.binding.pulseaudio.internal.items.SinkInput;
36 import org.openhab.core.audio.AudioSink;
37 import org.openhab.core.config.core.Configuration;
38 import org.openhab.core.library.types.DecimalType;
39 import org.openhab.core.library.types.IncreaseDecreaseType;
40 import org.openhab.core.library.types.OnOffType;
41 import org.openhab.core.library.types.PercentType;
42 import org.openhab.core.library.types.StringType;
43 import org.openhab.core.thing.Bridge;
44 import org.openhab.core.thing.ChannelUID;
45 import org.openhab.core.thing.Thing;
46 import org.openhab.core.thing.ThingStatus;
47 import org.openhab.core.thing.ThingTypeUID;
48 import org.openhab.core.thing.ThingUID;
49 import org.openhab.core.thing.binding.BaseThingHandler;
50 import org.openhab.core.thing.binding.ThingHandler;
51 import org.openhab.core.types.Command;
52 import org.openhab.core.types.RefreshType;
53 import org.openhab.core.types.State;
54 import org.openhab.core.types.UnDefType;
55 import org.osgi.framework.BundleContext;
56 import org.osgi.framework.ServiceRegistration;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
61 * The {@link PulseaudioHandler} is responsible for handling commands, which are
62 * sent to one of the channels.
64 * @author Tobias Bräutigam - Initial contribution
66 public class PulseaudioHandler extends BaseThingHandler implements DeviceStatusListener {
68 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
69 .unmodifiableSet(Stream.of(SINK_THING_TYPE, COMBINED_SINK_THING_TYPE, SINK_INPUT_THING_TYPE,
70 SOURCE_THING_TYPE, SOURCE_OUTPUT_THING_TYPE).collect(Collectors.toSet()));
72 private int refresh = 60; // refresh every minute as default
73 private ScheduledFuture<?> refreshJob;
75 private PulseaudioBridgeHandler bridgeHandler;
77 private final Logger logger = LoggerFactory.getLogger(PulseaudioHandler.class);
81 private PulseAudioAudioSink audioSink;
83 private Integer savedVolume;
85 private Map<String, ServiceRegistration<AudioSink>> audioSinkRegistrations = new ConcurrentHashMap<>();
87 private BundleContext bundleContext;
89 public PulseaudioHandler(Thing thing, BundleContext bundleContext) {
91 this.bundleContext = bundleContext;
95 public void initialize() {
96 Configuration config = getThing().getConfiguration();
97 name = (String) config.get(DEVICE_PARAMETER_NAME);
99 // until we get an update put the Thing offline
100 updateStatus(ThingStatus.OFFLINE);
101 deviceOnlineWatchdog();
103 // if it's a SINK thing, then maybe we have to activate the audio sink
104 if (PulseaudioBindingConstants.SINK_THING_TYPE.equals(thing.getThingTypeUID())) {
105 // check the property to see if we it's enabled :
106 Boolean sinkActivated = (Boolean) thing.getConfiguration()
107 .get(PulseaudioBindingConstants.DEVICE_PARAMETER_AUDIO_SINK_ACTIVATION);
108 if (sinkActivated != null && sinkActivated) {
114 private void audioSinkSetup() {
115 final PulseaudioHandler thisHandler = this;
116 scheduler.submit(new Runnable() {
119 // Register the sink as an audio sink in openhab
120 logger.trace("Registering an audio sink for pulse audio sink thing {}", thing.getUID());
121 PulseAudioAudioSink audioSink = new PulseAudioAudioSink(thisHandler, scheduler);
122 setAudioSink(audioSink);
124 audioSink.connectIfNeeded();
125 } catch (IOException e) {
126 logger.warn("pulseaudio binding cannot connect to the module-simple-protocol-tcp on {} ({})",
127 getHost(), e.getMessage());
128 } catch (InterruptedException i) {
129 logger.info("Interrupted during sink audio connection: {}", i.getMessage());
132 audioSink.scheduleDisconnect();
134 @SuppressWarnings("unchecked")
135 ServiceRegistration<AudioSink> reg = (ServiceRegistration<AudioSink>) bundleContext
136 .registerService(AudioSink.class.getName(), audioSink, new Hashtable<>());
137 audioSinkRegistrations.put(thing.getUID().toString(), reg);
143 public void dispose() {
144 if (refreshJob != null && !refreshJob.isCancelled()) {
145 refreshJob.cancel(true);
148 updateStatus(ThingStatus.OFFLINE);
149 if (bridgeHandler != null) {
150 bridgeHandler.unregisterDeviceStatusListener(this);
151 bridgeHandler = null;
153 logger.trace("Thing {} {} disposed.", getThing().getUID(), name);
156 if (audioSink != null) {
157 audioSink.disconnect();
160 // Unregister the potential pulse audio sink's audio sink
161 ServiceRegistration<AudioSink> reg = audioSinkRegistrations.remove(getThing().getUID().toString());
163 logger.trace("Unregistering the audio sync service for pulse audio sink thing {}", getThing().getUID());
168 private void deviceOnlineWatchdog() {
169 Runnable runnable = () -> {
171 PulseaudioBridgeHandler bridgeHandler = getPulseaudioBridgeHandler();
172 if (bridgeHandler != null) {
173 if (bridgeHandler.getDevice(name) == null) {
174 updateStatus(ThingStatus.OFFLINE);
175 bridgeHandler = null;
177 updateStatus(ThingStatus.ONLINE);
180 logger.debug("Bridge for pulseaudio device {} not found.", name);
181 updateStatus(ThingStatus.OFFLINE);
183 } catch (Exception e) {
184 logger.debug("Exception occurred during execution: {}", e.getMessage(), e);
185 bridgeHandler = null;
189 refreshJob = scheduler.scheduleWithFixedDelay(runnable, 0, refresh, TimeUnit.SECONDS);
192 private synchronized PulseaudioBridgeHandler getPulseaudioBridgeHandler() {
193 if (this.bridgeHandler == null) {
194 Bridge bridge = getBridge();
195 if (bridge == null) {
196 logger.debug("Required bridge not defined for device {}.", name);
199 ThingHandler handler = bridge.getHandler();
200 if (handler instanceof PulseaudioBridgeHandler) {
201 this.bridgeHandler = (PulseaudioBridgeHandler) handler;
202 this.bridgeHandler.registerDeviceStatusListener(this);
204 logger.debug("No available bridge handler found for device {} bridge {} .", name, bridge.getUID());
208 return this.bridgeHandler;
212 public void handleCommand(ChannelUID channelUID, Command command) {
213 PulseaudioBridgeHandler bridge = getPulseaudioBridgeHandler();
214 if (bridge == null) {
215 logger.warn("pulseaudio server bridge handler not found. Cannot handle command without bridge.");
218 if (command instanceof RefreshType) {
219 bridge.handleCommand(channelUID, command);
223 AbstractAudioDeviceConfig device = bridge.getDevice(name);
224 if (device == null) {
225 logger.warn("device {} not found", name);
226 updateStatus(ThingStatus.OFFLINE);
227 bridgeHandler = null;
230 State updateState = UnDefType.UNDEF;
231 if (channelUID.getId().equals(VOLUME_CHANNEL)) {
232 if (command instanceof IncreaseDecreaseType) {
233 // refresh to get the current volume level
234 bridge.getClient().update();
235 device = bridge.getDevice(name);
236 int oldVolume = device.getVolume();
237 int newVolume = oldVolume;
238 if (command.equals(IncreaseDecreaseType.INCREASE)) {
239 newVolume = Math.min(100, oldVolume + 5);
241 if (command.equals(IncreaseDecreaseType.DECREASE)) {
242 newVolume = Math.max(0, oldVolume - 5);
244 bridge.getClient().setVolumePercent(device, newVolume);
245 updateState = new PercentType(newVolume);
246 savedVolume = newVolume;
247 } else if (command instanceof PercentType) {
248 DecimalType volume = (DecimalType) command;
249 bridge.getClient().setVolumePercent(device, volume.intValue());
250 updateState = (PercentType) command;
251 savedVolume = volume.intValue();
252 } else if (command instanceof DecimalType) {
254 DecimalType volume = (DecimalType) command;
255 bridge.getClient().setVolume(device, volume.intValue());
256 updateState = (DecimalType) command;
257 savedVolume = volume.intValue();
259 } else if (channelUID.getId().equals(MUTE_CHANNEL)) {
260 if (command instanceof OnOffType) {
261 bridge.getClient().setMute(device, OnOffType.ON.equals(command));
262 updateState = (OnOffType) command;
264 } else if (channelUID.getId().equals(SLAVES_CHANNEL)) {
265 if (device instanceof Sink && ((Sink) device).isCombinedSink()) {
266 if (command instanceof StringType) {
267 List<Sink> slaves = new ArrayList<>();
268 for (String slaveName : command.toString().split(",")) {
269 Sink slave = bridge.getClient().getSink(slaveName.trim());
274 if (!slaves.isEmpty()) {
275 bridge.getClient().setCombinedSinkSlaves(((Sink) device), slaves);
279 logger.error("{} is no combined sink", device);
281 } else if (channelUID.getId().equals(ROUTE_TO_SINK_CHANNEL)) {
282 if (device instanceof SinkInput) {
284 if (command instanceof DecimalType) {
285 newSink = bridge.getClient().getSink(((DecimalType) command).intValue());
287 newSink = bridge.getClient().getSink(command.toString());
289 if (newSink != null) {
290 logger.debug("rerouting {} to {}", device, newSink);
291 bridge.getClient().moveSinkInput(((SinkInput) device), newSink);
292 updateState = new StringType(newSink.getPaName());
294 logger.error("no sink {} found", command.toString());
298 logger.trace("updating {} to {}", channelUID, updateState);
299 if (!updateState.equals(UnDefType.UNDEF)) {
300 updateState(channelUID, updateState);
306 * Use last checked volume for faster access
310 public int getLastVolume() {
311 if (savedVolume == null) {
312 PulseaudioBridgeHandler bridge = getPulseaudioBridgeHandler();
313 AbstractAudioDeviceConfig device = bridge.getDevice(name);
314 // refresh to get the current volume level
315 bridge.getClient().update();
316 device = bridge.getDevice(name);
317 savedVolume = device.getVolume();
319 return savedVolume == null ? 50 : savedVolume;
322 public void setVolume(int volume) {
323 PulseaudioBridgeHandler bridge = getPulseaudioBridgeHandler();
324 AbstractAudioDeviceConfig device = bridge.getDevice(name);
325 bridge.getClient().setVolumePercent(device, volume);
326 updateState(VOLUME_CHANNEL, new PercentType(volume));
327 savedVolume = volume;
331 public void onDeviceStateChanged(ThingUID bridge, AbstractAudioDeviceConfig device) {
332 if (device.getPaName().equals(name)) {
333 updateStatus(ThingStatus.ONLINE);
334 logger.debug("Updating states of {} id: {}", device, VOLUME_CHANNEL);
335 savedVolume = device.getVolume();
336 updateState(VOLUME_CHANNEL, new PercentType(savedVolume));
337 updateState(MUTE_CHANNEL, device.isMuted() ? OnOffType.ON : OnOffType.OFF);
338 updateState(STATE_CHANNEL,
339 device.getState() != null ? new StringType(device.getState().toString()) : new StringType("-"));
340 if (device instanceof SinkInput) {
341 updateState(ROUTE_TO_SINK_CHANNEL,
342 ((SinkInput) device).getSink() != null
343 ? new StringType(((SinkInput) device).getSink().getPaName())
344 : new StringType("-"));
346 if (device instanceof Sink && ((Sink) device).isCombinedSink()) {
347 updateState(SLAVES_CHANNEL, new StringType(String.join(",", ((Sink) device).getCombinedSinkNames())));
352 public String getHost() {
353 Bridge bridge = getBridge();
354 if (bridge != null) {
355 return (String) bridge.getConfiguration().get(PulseaudioBindingConstants.BRIDGE_PARAMETER_HOST);
357 logger.error("A bridge must be configured for this pulseaudio thing");
363 * This method will scan the pulseaudio server to find the port on which the module/sink is listening
364 * If no module is listening, then it will command the module to load on the pulse audio server,
366 * @return the port on which the pulseaudio server is listening for this sink
367 * @throws InterruptedException when interrupted during the loading module wait
369 public int getSimpleTcpPort() throws InterruptedException {
370 Integer simpleTcpPortPref = ((BigDecimal) getThing().getConfiguration()
371 .get(PulseaudioBindingConstants.DEVICE_PARAMETER_AUDIO_SINK_PORT)).intValue();
373 PulseaudioBridgeHandler bridgeHandler = getPulseaudioBridgeHandler();
374 AbstractAudioDeviceConfig device = bridgeHandler.getDevice(name);
375 return getPulseaudioBridgeHandler().getClient().loadModuleSimpleProtocolTcpIfNeeded(device, simpleTcpPortPref)
376 .orElse(simpleTcpPortPref);
379 public int getIdleTimeout() {
380 return ((BigDecimal) getThing().getConfiguration()
381 .get(PulseaudioBindingConstants.DEVICE_PARAMETER_AUDIO_SINK_IDLE_TIMEOUT)).intValue();
385 public void onDeviceRemoved(PulseaudioBridgeHandler bridge, AbstractAudioDeviceConfig device) {
386 if (device.getPaName().equals(name)) {
387 bridgeHandler.unregisterDeviceStatusListener(this);
388 bridgeHandler = null;
389 audioSink.disconnect();
391 updateStatus(ThingStatus.OFFLINE);
396 public void onDeviceAdded(Bridge bridge, AbstractAudioDeviceConfig device) {
397 logger.trace("new device discovered {} by {}", device, bridge);
400 public void setAudioSink(PulseAudioAudioSink audioSink) {
401 this.audioSink = audioSink;