/** * Copyright (c) 2010-2016 by the respective copyright holders. * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html */ package org.openhab.binding.pulseaudio.internal; import java.io.IOException; import java.util.ArrayList; import java.util.Dictionary; import java.util.Enumeration; import java.util.HashMap; import java.util.Hashtable; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.commons.lang.StringUtils; import org.openhab.binding.pulseaudio.PulseaudioBindingProvider; import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig; import org.openhab.binding.pulseaudio.internal.items.Sink; import org.openhab.core.binding.AbstractActiveBinding; import org.openhab.core.items.GroupItem; import org.openhab.core.items.Item; import org.openhab.core.library.items.DimmerItem; import org.openhab.core.library.items.NumberItem; import org.openhab.core.library.items.StringItem; import org.openhab.core.library.items.SwitchItem; import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.IncreaseDecreaseType; import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.PercentType; import org.openhab.core.library.types.StringType; import org.openhab.core.types.Command; import org.openhab.core.types.State; import org.openhab.core.types.UnDefType; import org.osgi.service.cm.ConfigurationException; import org.osgi.service.cm.ManagedService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * <p> * This class implements a binding of pulseaudio items to openHAB. The binding * configurations are provided by the {@link PulseaudioBindingProvider}. * </p> * * <p> * The format of the binding configuration is simple and looks like this: * </p> * pulseaudio="<serverId:item-name>" where <serverId> is the * serverId the pulseaudio server as it is configured in the openhab.cfg and * <item-name> is the name of the an audio-item (audio-item are items of * type sink,source,sink-input,source-output) in the pulseaudio server * <p> * Switch items with this binding will allow to mute/unmute a pulseaudio item<br/> * Dimmer items will allow to change the volume of a pulseaudio item. * </p> * * TODO: - add a possibility to move sink-inputs to other sinks - add a * possibility to change the members of a combined sink * * @author Tobias Bräutigam * @since 1.2.0 */ public class PulseaudioBinding extends AbstractActiveBinding<PulseaudioBindingProvider>implements ManagedService { private static final Logger logger = LoggerFactory.getLogger(PulseaudioBinding.class); public Map<String, PulseaudioServerConfig> serverConfigCache = new HashMap<String, PulseaudioServerConfig>(); private Hashtable<String, PulseaudioClient> clients = new Hashtable<String, PulseaudioClient>(); /** * the refresh interval which is used to poll the pulseaudio servers (e.g. * recording state; defaults to 60000ms) */ private long refreshInterval = 60000; /** * RegEx to validate a pulseaudio server config * <code>'^(.*?)\\.(host|port)$'</code> */ private static final Pattern EXTRACT_CONFIG_PATTERN = Pattern.compile("^(.*?)\\.(host|port)$"); @Override public void activate() { super.activate(); setProperlyConfigured(true); logger.debug("pulseaudio binding activated"); } @Override public void deactivate() { logger.debug("pulseaudio binding deactivated"); for (PulseaudioClient client : clients.values()) { client.disconnect(); } } /** * {@inheritDoc} */ @Override protected long getRefreshInterval() { return refreshInterval; } /** * {@inheritDoc} */ @Override protected String getName() { return "Pulseaudio Monitor Service"; } @Override public void internalReceiveCommand(String itemName, Command command) { PulseaudioBindingProvider provider = findFirstMatchingBindingProvider(itemName, command); if (provider == null) { logger.warn("doesn't find matching binding provider [itemName={}, command={}]", itemName, command); return; } String audioItemName = provider.getItemName(itemName); String serverId = provider.getServerId(itemName); // Item item = provider.getItem(itemName); String paCommand = provider.getCommand(itemName); PulseaudioCommandTypeMapping pulseaudioCommandType = null; if (paCommand != null && !paCommand.isEmpty()) { try { pulseaudioCommandType = PulseaudioCommandTypeMapping.valueOf(paCommand.toUpperCase()); } catch (IllegalArgumentException e) { logger.warn( "unknown command specified for the given itemName [itemName={}, audio-item-name={}, serverId={}, command={}] => querying for values aborted!", new Object[] { itemName, audioItemName, serverId, command }); } } PulseaudioClient client = clients.get(serverId); if (client == null) { // try to reconnect if the server is configured if (serverConfigCache.containsKey(serverId)) { connect(serverId, serverConfigCache.get(serverId)); client = clients.get(serverId); } } if (client == null) { logger.warn("does't find matching pulseaudio client [itemName={}, serverId={}]", itemName, serverId); return; } if (audioItemName != null && !audioItemName.isEmpty()) { AbstractAudioDeviceConfig audioItem = client.getGenericAudioItem(audioItemName); if (audioItem == null) { logger.warn("no corresponding audio-item found [audioItemName={}]", audioItemName); return; } State updateState = UnDefType.UNDEF; if (command instanceof IncreaseDecreaseType) { int volume = audioItem.getVolume(); logger.debug(audioItemName + " volume is " + volume); if (command.equals(IncreaseDecreaseType.INCREASE)) { volume = Math.min(100, volume + 5); } if (command.equals(IncreaseDecreaseType.DECREASE)) { volume = Math.max(0, volume - 5); } logger.debug("setting " + audioItemName + " volume to " + volume); client.setVolumePercent(audioItem, volume); updateState = new PercentType(volume); } else if (command instanceof PercentType) { client.setVolumePercent(audioItem, Integer.valueOf(command.toString())); updateState = (PercentType) command; } else if (command instanceof DecimalType) { if (pulseaudioCommandType == null || pulseaudioCommandType.equals(PulseaudioCommandTypeMapping.VOLUME)) { // set volume client.setVolume(audioItem, Integer.valueOf(command.toString())); updateState = (DecimalType) command; } // all other pulseaudioCommandType's for DecimalTypes are // read-only and // therefore we do nothing here } else if (command instanceof OnOffType) { if (pulseaudioCommandType == null) { // Default behaviour when no command is specified => mute client.setMute(audioItem, ((OnOffType) command).equals(OnOffType.ON)); updateState = (OnOffType) command; } else { switch (pulseaudioCommandType) { case EXISTS: // logically the module of the audio item should be // unloaded here if command==OFF // but it cannot be loaded again, when unloaded once so // we better do nothing here break; case MUTED: client.setMute(audioItem, ((OnOffType) command).equals(OnOffType.ON)); updateState = (OnOffType) command; break; case RUNNING: case CORKED: case SUSPENDED: case IDLE: // the state of an audio-item cannot be changed break; case ID: case MODULE_ID: // the id or module-id of an audio-item cannot be // changed break; case VOLUME: if (((OnOffType) command).equals(OnOffType.ON)) { // Set Volume to 100% client.setVolume(audioItem, 100); } else { // set volume to 0 client.setVolume(audioItem, 100); } updateState = (OnOffType) command; break; case SLAVE_SINKS: // also an read-only field break; } } } else if (command instanceof StringType) { if (pulseaudioCommandType != null) { switch (pulseaudioCommandType) { case CORKED: case EXISTS: case ID: case IDLE: case MODULE_ID: case MUTED: case RUNNING: case SUSPENDED: case VOLUME: // no action here break; case SLAVE_SINKS: if (audioItem instanceof Sink && ((Sink) audioItem).isCombinedSink()) { // change the slave sinks of the given combined sink // to the new value Sink mainSink = (Sink) audioItem; ArrayList<Sink> slaveSinks = new ArrayList<Sink>(); for (String slaveSinkName : StringUtils.split(command.toString(), ",")) { Sink slaveSink = client.getSink(slaveSinkName); if (slaveSink != null) { slaveSinks.add(slaveSink); } } logger.debug(slaveSinks.size() + " slave sinks"); if (slaveSinks.size() > 0) { client.setCombinedSinkSlaves(mainSink, slaveSinks); } } break; } } } if (!updateState.equals(UnDefType.UNDEF)) { eventPublisher.postUpdate(itemName, updateState); } } else if (command instanceof StringType) { // send the command directly to the pulseaudio server client.sendCommand(command.toString()); eventPublisher.postUpdate(itemName, (StringType) command); } } /** * Find the first matching {@link PulseaudioBindingProvider} according to * <code>itemName</code>. * * @param itemName * * @return the matching binding provider or <code>null</code> if no binding * provider could be found */ private PulseaudioBindingProvider findFirstMatchingBindingProvider(String itemName, Command command) { PulseaudioBindingProvider firstMatchingProvider = null; Class<? extends Item> itemClass = mapCommandToItemType(command); for (PulseaudioBindingProvider provider : this.providers) { String audioItemName = provider.getItemName(itemName); Class<? extends Item> itemType = provider.getItemType(itemName); if (itemClass.equals(itemType)) { // StringItems do not need an audioItemName firstMatchingProvider = provider; break; } else if (audioItemName != null && itemType != null && itemType.isAssignableFrom(itemClass)) { firstMatchingProvider = provider; break; } } return firstMatchingProvider; } private Class<? extends Item> mapCommandToItemType(Command command) { if (command instanceof IncreaseDecreaseType) { return DimmerItem.class; } else if (command instanceof PercentType) { return DimmerItem.class; } else if (command instanceof DecimalType) { return NumberItem.class; } else if (command instanceof OnOffType) { return SwitchItem.class; } else if (command instanceof StringType) { return StringItem.class; } else { logger.warn("No explicit mapping found for command type '{}' - return StringItem.class instead", command.getClass().getSimpleName()); return StringItem.class; } } /** * Create a new TCP connection to the pulseaudio server with the given * <code>host</code> and <code>port</code> * * @param host * @param port */ private void connect(String serverId, PulseaudioServerConfig serverConfig) { if (serverConfig.host != null && serverConfig.port > 0) { try { clients.put(serverId, new PulseaudioClient(serverConfig.host, serverConfig.port)); boolean isConnected = clients.get(serverId).isConnected(); if (isConnected) { logger.info("Established connection to Pulseaudio server on Host '{}' Port '{}'.", serverConfig.host, serverConfig.port); // refesh the states execute(); } else { logger.warn("Establishing connection to Pulseaudio server [Host '{}' Port '{}'] timed out.", serverConfig.host, serverConfig.port); } } catch (IOException ioe) { logger.error("Couldn't connect to Pulsaudio server [Host '" + serverConfig.host + "' Port '" + serverConfig.port + "']: ", ioe.getLocalizedMessage()); } } else { logger.warn( "Couldn't connect to Pulseaudio server because of missing connection parameters [Host '{}' Port '{}'].", serverConfig.host, serverConfig.port); } } /** * @{inheritDoc */ @Override @SuppressWarnings("incomplete-switch") public void execute() { List<PulseaudioClient> updatedClients = new ArrayList<PulseaudioClient>(); for (PulseaudioBindingProvider provider : providers) { for (String itemName : provider.getItemNames()) { String audioItemName = provider.getItemName(itemName); String serverId = provider.getServerId(itemName); Class<? extends Item> itemType = provider.getItemType(itemName); String command = provider.getCommand(itemName); PulseaudioCommandTypeMapping commandType = null; if (command != null && !command.isEmpty()) { try { commandType = PulseaudioCommandTypeMapping.valueOf(command.toUpperCase()); } catch (IllegalArgumentException e) { logger.warn( "unknown command specified for the given itemName [itemName={}, audio-item-name={}, serverId={}, command={}] => querying for values aborted!", new Object[] { itemName, audioItemName, serverId, command }); continue; } } if (itemType.isAssignableFrom(GroupItem.class) || itemType.isAssignableFrom(StringItem.class)) { // GroupItems/StringItems should not receive any updated // directly continue; } PulseaudioClient client = clients.get(serverId); if (client == null) { logger.warn( "connection to pulseaudio server in not available " + "for the given itemName [itemName={}, audio-item-name={}, serverId={}, command={}] => querying for values aborted!", new Object[] { itemName, audioItemName, serverId, command }); continue; } if (audioItemName == null) { logger.warn( "audio-item-name isn't configured properly " + "for the given itemName [itemName={}, audio-item-name={}, serverId={}, command={}] => querying for values aborted!", new Object[] { itemName, audioItemName, serverId, command }); continue; } if (!updatedClients.contains(client)) { // update the clients data structure to avoid // inconsistencies client.update(); updatedClients.add(client); } State value = UnDefType.UNDEF; AbstractAudioDeviceConfig audioItem = client.getGenericAudioItem(audioItemName); if (audioItem != null) { // item found if (itemType.isAssignableFrom(SwitchItem.class)) { if (commandType == null) { // Check if item is unmuted and running if (!audioItem.isMuted() && audioItem.getState() != null && audioItem.getState().equals(AbstractAudioDeviceConfig.State.RUNNING)) { value = OnOffType.ON; } else { value = OnOffType.OFF; } } else { switch (commandType) { case EXISTS: value = OnOffType.ON; break; case MUTED: value = audioItem.isMuted() ? OnOffType.ON : OnOffType.OFF; break; case RUNNING: case CORKED: case SUSPENDED: case IDLE: try { value = audioItem.getState() != null && audioItem.getState() .equals(AbstractAudioDeviceConfig.State.valueOf(commandType.name())) ? OnOffType.ON : OnOffType.OFF; } catch (IllegalArgumentException e) { logger.warn("no corresponding AbstractAudioDeviceConfig.State found for " + commandType.name()); } break; } } } else if (itemType.isAssignableFrom(DimmerItem.class)) { value = new PercentType(audioItem.getVolume()); } else if (itemType.isAssignableFrom(NumberItem.class)) { if (commandType == null) { // when no other pulseaudioCommand specified, we use // VOLUME value = new DecimalType(audioItem.getVolume()); } else { // NumberItems can only handle VOLUME, MODULE_ID and // ID command types switch (commandType) { case VOLUME: value = new DecimalType(audioItem.getVolume()); break; case ID: value = new DecimalType(audioItem.getId()); break; case MODULE_ID: if (audioItem.getModule() != null) { value = new DecimalType(audioItem.getModule().getId()); } break; } } } else if (itemType.isAssignableFrom(StringItem.class)) { if (commandType == null) { value = new StringType(audioItem.toString()); } else if (audioItem instanceof Sink) { Sink sink = (Sink) audioItem; switch (commandType) { case SLAVE_SINKS: if (sink.isCombinedSink()) { value = new StringType(StringUtils.join(sink.getCombinedSinkNames(), ",")); } break; } } } else { logger.debug("unhandled item type [type={}, name={}]", itemType.getClass(), audioItemName); } } else if (itemType.isAssignableFrom(SwitchItem.class)) { value = OnOffType.OFF; } eventPublisher.postUpdate(itemName, value); } } } /** * Connects to all configured {@link PulseaudioClient}s */ private void connectAllPulseaudioServers() { for (String serverId : serverConfigCache.keySet()) { connect(serverId, serverConfigCache.get(serverId)); } } @Override @SuppressWarnings("rawtypes") public void updated(Dictionary<String, ?> config) throws ConfigurationException { if (config != null) { Enumeration keys = config.keys(); while (keys.hasMoreElements()) { String key = (String) keys.nextElement(); // the config-key enumeration contains additional keys that we // don't want to process here ... if ("service.pid".equals(key)) { continue; } Matcher matcher = EXTRACT_CONFIG_PATTERN.matcher(key); if (!matcher.matches()) { logger.debug("given pulseaudio-config-key '" + key + "' does not follow the expected pattern '<serverId>.<host|port>'"); continue; } matcher.reset(); matcher.find(); String serverId = matcher.group(1); PulseaudioServerConfig serverConfig = serverConfigCache.get(serverId); if (serverConfig == null) { serverConfig = new PulseaudioServerConfig(); serverConfigCache.put(serverId, serverConfig); } String configKey = matcher.group(2); String value = (String) config.get(key); if ("host".equals(configKey)) { serverConfig.host = value; } else if ("port".equals(configKey)) { serverConfig.port = Integer.valueOf(value); } else { throw new ConfigurationException(configKey, "the given configKey '" + configKey + "' is unknown"); } } connectAllPulseaudioServers(); } } /** * Internal data structure which carries the connection details of one * Pulseaudio server (there could be several) * * @author Tobias Bräutigam */ static class PulseaudioServerConfig { String host = "localhost"; int port = 4712; PulseaudioClient client; @Override public String toString() { return "Pulseaudio [host=" + host + ", port=" + port + "]"; } } }