/** * 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.freeswitch.internal; import static org.openhab.binding.freeswitch.internal.FreeswitchMessageHeader.*; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.util.ArrayList; import java.util.Dictionary; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.ListIterator; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.commons.lang.StringUtils; import org.freeswitch.esl.client.IEslEventListener; import org.freeswitch.esl.client.inbound.Client; import org.freeswitch.esl.client.inbound.InboundConnectionFailure; import org.freeswitch.esl.client.transport.event.EslEvent; import org.freeswitch.esl.client.transport.message.EslMessage; import org.openhab.binding.freeswitch.FreeswitchBindingProvider; import org.openhab.core.binding.AbstractBinding; 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.OnOffType; import org.openhab.core.library.types.StringType; import org.openhab.core.types.Command; import org.openhab.library.tel.items.CallItem; import org.openhab.library.tel.types.CallType; import org.osgi.service.cm.ConfigurationException; import org.osgi.service.cm.ManagedService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * FreeswitchBinding connects to a Freeswitch instance using a ESL Client * connection. From this connection we listen for call life cycle events, message * waiting (MWI) events as well as send generic API commands. * * @author Dan Cunningham * @since 1.4.0 */ public class FreeswitchBinding extends AbstractBinding<FreeswitchBindingProvider> implements ManagedService, IEslEventListener { private static final Logger logger = LoggerFactory.getLogger(FreeswitchBinding.class); private static int DEFAULT_PORT = 8021; /* * How long we check to reconnect */ private long WATCHDOG_INTERVAL = 30000; // all calls are cached, we can lookup channles by thier UUID protected Map<String, Channel> eventCache; // map channels by UUID to one or more binding configs protected Map<String, LinkedList<FreeswitchBindingConfig>> itemMap; // Maps freeswitch accounts (vmail boxes) to MessageWaiting objects protected Map<String, MWIModel> mwiCache; private Client inboudClient; private String host; private String password; private int port; private WatchDog watchDog; public FreeswitchBinding() { } @Override public void activate() { logger.trace("activate() is called!"); } @Override public void deactivate() { logger.trace("deactivate() is called!"); stopWatchdog(); disconnect(); } /** * @{inheritDoc} */ @Override protected void internalReceiveCommand(String itemName, Command command) { logger.trace("Received command for item '{}' with command '{}'", itemName, command); for (FreeswitchBindingProvider provider : providers) { FreeswitchBindingConfig config = provider.getFreeswitchBindingConfig(itemName); switch (config.getType()) { case CMD_API: { if (!(command instanceof StringType)) { logger.warn("could not process command '{}' for item '{}': command is not a StringType", command, itemName); return; } String str = ((StringType) command).toString().toLowerCase(); String response = executeApiCommand(str); eventPublisher.postUpdate(itemName, new StringType(response)); } break; default: break; } } } protected void addBindingProvider(FreeswitchBindingProvider bindingProvider) { super.addBindingProvider(bindingProvider); } protected void removeBindingProvider(FreeswitchBindingProvider bindingProvider) { super.removeBindingProvider(bindingProvider); } /** * {@inheritDoc} */ @Override public void updated(Dictionary<String, ?> config) throws ConfigurationException { logger.trace("updated() is called!"); if (config != null) { startWatchdog(); port = DEFAULT_PORT; host = (String) config.get("host"); password = (String) config.get("password"); String portString = (String) config.get("port"); if (StringUtils.isNotBlank(portString)) { port = Integer.parseInt(portString); } eventCache = new LinkedHashMap<String, Channel>(); mwiCache = new HashMap<String, FreeswitchBinding.MWIModel>(); itemMap = new LinkedHashMap<String, LinkedList<FreeswitchBindingConfig>>(); try { connect(); } catch (InboundConnectionFailure e) { logger.error("Could not connect to freeswitch server", e); // clean up disconnect(); } } else { // if we no longer have a config, make sure we are not connected and // that our watchdog thread is not running. stopWatchdog(); disconnect(); } } @Override public void eventReceived(EslEvent event) { logger.debug("Received ESLEvent {}", event.getEventName()); logger.trace(printEvent(event)); if (CHANNEl_CREATE.matches(event.getEventName())) { handleNewCallEvent(event); } else if (CHANNEL_DESTROY.matches(event.getEventName())) { handleHangupCallEvent(event); } else if (MESSAGE_WAITING.matches(event.getEventName())) { handleMessageWaiting(event); } } @Override public void backgroundJobResultReceived(EslEvent arg0) { } /** * Starts our watchdog thread to reconnect */ private void startWatchdog() { // start our watch dog if we have been configued at least // once, we will stop when the binding is unloaded if (watchDog == null || !watchDog.isRunning()) { watchDog = new WatchDog(); watchDog.start(); } } /** * stops our watchdog thread; */ private void stopWatchdog() { if (watchDog != null) { watchDog.stopRunning(); } } /** * Connect inbound client to freeswitch * * @throws InboundConnectionFailure */ private void connect() throws InboundConnectionFailure { disconnect(); logger.debug("Connecting to {} on port {} with pass {}", host, port, password); inboudClient = new Client(); inboudClient.connect(host, port, password, 10); inboudClient.addEventListener(this); inboudClient.setEventSubscriptions("plain", String.format("%s %s %s", CHANNEl_CREATE, CHANNEL_DESTROY, MESSAGE_WAITING)); logger.debug(String.format("Connected")); initMessageItems(); } /** * disconnect inbound client from freeswitch */ private void disconnect() { if (inboudClient != null) { try { inboudClient.close(); } catch (Exception ignored) { } finally { inboudClient = null; } } } /** * Handle Answer or Media (ringing) events and add an entry to our cache * * @param event */ private void handleNewCallEvent(EslEvent event) { String uuid = getHeader(event, UUID); logger.debug("Adding Call with uuid " + uuid); Channel channel = new Channel(event); // we should not get duplicate events, but lets be safe if (eventCache.containsKey(uuid)) { return; } eventCache.put(uuid, channel); itemMap.put(uuid, new LinkedList<FreeswitchBindingConfig>()); CallType call = channel.getCall(); logger.debug("new call to : {} from : {}", call.getDestNum(), call.getOrigNum()); for (FreeswitchBindingProvider provider : providers) { for (String itemName : provider.getItemNames()) { FreeswitchBindingConfig config = provider.getFreeswitchBindingConfig(itemName); if (config.getType() == FreeswitchBindingType.ACTIVE) { /* * Add the item if it is filtered and matches or if it is * un-filtered and inbound */ if ((config.filtered() && matchCall(channel, config.getArgument())) || (!config.filtered() && isInboundCall(channel))) { itemMap.get(uuid).add(config); newCallItemUpdate(config, channel); } } } } } /** * MatchCall will attempt to match all the filters in a given filterString * against the headers in a Channel. If all filters are satisfied * (matched) then we return true, if any filter fails we will stop * processing and return false. * * @param channel * @param filterString * @return true if all filters match, false if any one does not. */ private boolean matchCall(Channel channel, String filterString) { logger.debug("Trying to match filter string {}", filterString); // split our filter string rule pairs String[] filters = filterString.split(","); // out return value boolean matched = true; // for each filter try and match any channel headers for (String filter : filters) { // break filter into header key and value String[] args = filter.split(":"); // check that we have a key and value, and that neither is blank/null if (args.length == 2 && StringUtils.isNotBlank(args[0]) && StringUtils.isNotBlank(args[1])) { String eventHeader = channel.getEventHeader(args[0]); try { // is the header blank/null or does the filter value not match the header value if (StringUtils.isBlank(eventHeader) || !args[1].equals(URLDecoder.decode(eventHeader, "UTF-8"))) { // this item is filtered, but this call does not match matched = false; } } catch (UnsupportedEncodingException e) { logger.warn("Could not decode event header {}", eventHeader); matched = false; } } else { logger.warn("The filter string {} does not look valid, not updating item", filter); matched = false; } /* * we have failed one of the filters, stop processing */ if (!matched) { break; } } return matched; } /** * Check if this channel is an inbound call * * @param channel * @return true if the channel is inbound */ private boolean isInboundCall(Channel channel) { String direction = channel.getEventHeader(CALL_DIRECTION); return StringUtils.isNotBlank(direction) && "inbound".equals(direction); } /** * Handle channel destroy events and remove entries from our cache * * @param event */ private void handleHangupCallEvent(EslEvent event) { String uuid = getHeader(event, UUID); logger.debug("Removing Call with uuid " + uuid); eventCache.remove(uuid); LinkedList<FreeswitchBindingConfig> configs = itemMap.remove(getHeader(event, UUID)); if (configs != null) { for (FreeswitchBindingConfig config : configs) { endCallItemUpdate(config); } } } /** * Update items for new calls * * @param config * @param channel */ private void newCallItemUpdate(FreeswitchBindingConfig config, Channel channel) { if (config.getItemType().isAssignableFrom(SwitchItem.class)) { eventPublisher.postUpdate(config.getItemName(), OnOffType.ON); } else if (config.getItemType().isAssignableFrom(CallItem.class)) { eventPublisher.postUpdate(config.getItemName(), channel.getCall()); } else if (config.getItemType().isAssignableFrom(StringItem.class)) { eventPublisher.postUpdate(config.getItemName(), new StringType( String.format("%s : %s", channel.getEventHeader(CID_NAME), channel.getEventHeader(CID_NUMBER)))); } else { logger.warn("handleHangupCall - postUpdate for itemType '{}' is undefined", config.getItemName()); } } /** * update items on call end * * @param config */ private void endCallItemUpdate(FreeswitchBindingConfig config) { OnOffType activeState = OnOffType.OFF; ; CallType callType = (CallType) CallType.EMPTY; StringType callerId = StringType.EMPTY; /* * A channel has ended that has this item associated with it * We still need to check if this item is associated with other * channels. * We are going to iterate backwards to get the last added channel; */ ListIterator<String> it = new ArrayList<String>(itemMap.keySet()).listIterator(itemMap.size()); // if we get a match we will stop processing boolean match = false; while (it.hasPrevious()) { String uuid = it.previous(); for (FreeswitchBindingConfig c : itemMap.get(uuid)) { if (c.getItemName().equals(config.getItemName())) { Channel channel = eventCache.get(uuid); activeState = OnOffType.ON; callType = channel.getCall(); callerId = new StringType(String.format("%s : %s", channel.getEventHeader(CID_NAME), channel.getEventHeader(CID_NUMBER))); match = true; break; } } if (match) { break; } } if (config.getItemType().isAssignableFrom(SwitchItem.class)) { eventPublisher.postUpdate(config.getItemName(), activeState); } else if (config.getItemType().isAssignableFrom(CallItem.class)) { eventPublisher.postUpdate(config.getItemName(), callType); } else if (config.getItemType().isAssignableFrom(StringItem.class)) { eventPublisher.postUpdate(config.getItemName(), callerId); } else { logger.warn("handleHangupCall - postUpdate for itemType '{}' is undefined", config.getItemName()); } } /** * Handle message waiting indicator events (MWI) * * A MWI looks has the following format * * MWI-Messages-Waiting: yes * MWI-Message-Account: jonas@gauffin.com * MWI-Voice-Message: 2/1 (1/1) * * The voice message line format translates to: * total_new_messages / total_saved_messages (total_new_urgent_messages / total_saved_urgent_messages) * * @param event to parse */ private void handleMessageWaiting(EslEvent event) { logger.debug("MWI event\\n {}", event.toString()); for (String key : event.getEventHeaders().keySet()) { logger.debug("MWI Message header {} : {}", key, event.getEventHeaders().get(key)); } String account = null; try { account = URLDecoder.decode(getHeader(event, MWI_ACCOUNT), "UTF-8"); } catch (UnsupportedEncodingException e) { logger.error("Could not decode account for event {} : {}", event, e); return; } boolean waiting = "yes".equalsIgnoreCase(getHeader(event, MWI_WAITING)); String messagesString = getHeader(event, MWI_MESSAGE); logger.debug("Message header: {}", messagesString); if (StringUtils.isBlank(messagesString)) { logger.debug("message is not for us."); return; } Pattern pattern = Pattern.compile("([0-9]+)/([0-9]+).*"); Matcher matcher = pattern.matcher(messagesString); int messages = 0; if (matcher.matches()) { logger.debug("trying to parse message number {} ", matcher.group(1)); try { messages = Integer.parseInt(matcher.group(1)); } catch (Exception e) { logger.warn("Could not parse message number from message {} : {}", messagesString, e); } } logger.debug("Updating MWI to {} VMs", messages); mwiCache.put(account, new MWIModel(waiting, messages)); updateMessageWaitingItems(); } /** * update items for message waiting types for all providers */ private void updateMessageWaitingItems() { for (FreeswitchBindingProvider provider : providers) { for (String itemName : provider.getItemNames()) { FreeswitchBindingConfig config = provider.getFreeswitchBindingConfig(itemName); if (config.getType() == FreeswitchBindingType.MESSAGE_WAITING) { updateMessageWaitingItem(config); } } } } /** * update items for message waiting types * * @param itemName * @param config */ private void updateMessageWaitingItem(FreeswitchBindingConfig config) { MWIModel model = mwiCache.get(config.getArgument()); /* * see if this is for us */ if (model == null) { return; } if (config.getItemType().isAssignableFrom(SwitchItem.class)) { eventPublisher.postUpdate(config.getItemName(), model.mwi ? OnOffType.ON : OnOffType.OFF); } else if (config.getItemType().isAssignableFrom(NumberItem.class)) { eventPublisher.postUpdate(config.getItemName(), new DecimalType(model.messages)); } else { logger.warn("handle call for item type '{}' is undefined", config.getItemName()); } } /** * query freeswitch for the message count for VM accounts. This should * be done every time we connect to the system. */ private void initMessageItems() { mwiCache.clear(); for (FreeswitchBindingProvider provider : providers) { for (String itemName : provider.getItemNames()) { FreeswitchBindingConfig config = provider.getFreeswitchBindingConfig(itemName); if (config.getType() == FreeswitchBindingType.MESSAGE_WAITING) { String account = config.getArgument(); if (!mwiCache.containsKey(account) && clientValid()) { EslMessage msg = inboudClient.sendSyncApiCommand("vm_boxcount", account); if (msg.getBodyLines().size() == 1) { try { int messages = Integer.parseInt(msg.getBodyLines().get(0)); mwiCache.put(account, new MWIModel(messages > 0, messages)); updateMessageWaitingItem(config); } catch (Exception e) { logger.error("Could not parse messages", e); } } } } } } } /** * Execute a api command and return the body as a * single comma delimited String * * @param command * @return Each line of the response will be appended to the string, * delimited by a comma */ public String executeApiCommand(String command) { logger.debug("Trying to execute API command {}", command); if (!clientValid() && StringUtils.isBlank(command)) { logger.error("Bad command {}", command); return null; } String[] args = command.split(" ", 1); /* * if we do not have 2 args then this is not valid */ if (args.length == 0) { logger.error("Command did not contain a valid command string {}"); return null; } EslMessage msg = inboudClient.sendSyncApiCommand(args[0], args.length > 1 ? args[1] : ""); List<String> bodyLines = msg.getBodyLines(); StringBuilder builder = new StringBuilder(); for (String line : bodyLines) { if (builder.length() > 0) { builder.append(","); } builder.append(line); } return builder.toString(); } /** * Get a header from a esl event object * * @param event * @param name * @return */ private static String getHeader(EslEvent event, FreeswitchMessageHeader name) { return getHeader(event, name.toString()); } private static String getHeader(EslEvent event, String name) { return event.getEventHeaders().get(name); } private boolean clientValid() { return inboudClient != null && inboudClient.canSend(); } private String printEvent(EslEvent event) { Map<String, String> headers = event.getEventHeaders(); StringBuilder sb = new StringBuilder(); for (String key : headers.keySet()) { sb.append('\t').append(key).append(" = ").append(headers.get(key)).append('\n'); } return sb.toString(); } private class MWIModel { protected boolean mwi = false; protected int messages = 0; public MWIModel(boolean mwi, int messages) { super(); this.mwi = mwi; this.messages = messages; } } private class Channel { protected EslEvent event; public Channel(EslEvent newChannelEvent) { super(); this.event = newChannelEvent; } public CallType getCall() { String dest = getEventHeader(DEST_NUMBER); String orig = getEventHeader(ORIG_NUMBER); if (StringUtils.isBlank(dest)) { dest = "unknown"; } if (StringUtils.isBlank(orig)) { orig = "unknown"; } return new CallType(orig, dest); } public String getEventHeader(FreeswitchMessageHeader header) { return getEventHeader(header.toString()); } public String getEventHeader(String header) { return getHeader(event, header); } } /** * The Freeswitch ESL library we are using does not tell us when * a connection dies, we need to poll and reconnect, which is what the * WatchDog class does. * * @author daniel * */ private class WatchDog extends Thread { private boolean running; private Object lock = new Object(); public WatchDog() { super("Freeswitch WatchDog"); running = true; } @Override public void run() { /* * Check that our client is connected, try reconnecting if not */ while (running) { if (!clientValid()) { try { logger.warn("Client is not connected, reconnecting"); connect(); } catch (InboundConnectionFailure e) { logger.error("Could not connect to freeswitch server", e); } } synchronized (lock) { try { lock.wait(WATCHDOG_INTERVAL); } catch (InterruptedException ignored) { } } } } /** * Stops the watchdog from running */ public void stopRunning() { this.running = false; lock.notifyAll(); } public boolean isRunning() { return running; } } }