/** * 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.alarmdecoder.internal; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.net.Socket; import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Dictionary; import java.util.Enumeration; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.commons.lang.StringUtils; import org.openhab.binding.alarmdecoder.AlarmDecoderBindingProvider; import org.openhab.core.binding.AbstractActiveBinding; import org.openhab.core.binding.BindingProvider; import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.OpenClosedType; import org.openhab.core.library.types.StringType; import org.openhab.core.types.Command; import org.openhab.core.types.State; import org.osgi.service.cm.ConfigurationException; import org.osgi.service.cm.ManagedService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import gnu.io.CommPort; import gnu.io.CommPortIdentifier; import gnu.io.NoSuchPortException; import gnu.io.PortInUseException; import gnu.io.SerialPort; import gnu.io.UnsupportedCommOperationException; /** * The actual AlarmDecoderBinding * * Implementing it as an ActiveBinding and using the refresh to reestablish connections that * might have broken. * * @author Bernd Pfrommer * @since 1.6.0 */ public class AlarmDecoderBinding extends AbstractActiveBinding<AlarmDecoderBindingProvider> implements ManagedService { private static final Logger logger = LoggerFactory.getLogger(AlarmDecoderBinding.class); /** straight copy of the connection string */ private String m_connectString = null; /** hostname for the alarmdecoder process */ private String m_tcpHostName = null; /** port for the alarmdecoder process */ private int m_tcpPort = -1; /** name of serial device */ private String m_serialDeviceName = null; /** Interval between attempts to reestablish the connection */ private long refreshInterval = 10000; private BufferedReader m_reader = null; private BufferedWriter m_writer = null; private Socket m_socket = null; private SerialPort m_port = null; private int m_portSpeed = 115200; private Thread m_thread = null; private MsgReader m_msgReader = null; private boolean m_acceptCommands = false; private static HashMap<String, ADMsgType> s_startToMsgType = new HashMap<String, ADMsgType>(); // pretty disgusting to have a separate hash map to keep track of which // items have been updated. But where else to put per-item state? private HashMap<String, AlarmDecoderBindingConfig> m_unupdatedItems = new HashMap<String, AlarmDecoderBindingConfig>(); @Override protected String getName() { return "AlarmDecoder binding"; } @Override protected long getRefreshInterval() { return refreshInterval; } @Override public void deactivate() { disconnect(); } @Override public void execute() { // The framework calls this function once the binding has been configured, // so we use it as a hook to start the binding. // At the same time, should the socket get disconnected, it will // be reconnected when this method is called. synchronized (this) { if (m_socket == null && m_port == null) { connect(); } } } /** * Parses and stores the tcp configuration string of the binding configuration. * Expected form is tcp:hostname:port * * @param parts config string, split on ':' * @throws ConfigurationException */ private void parseTcpConfig(String[] parts) throws ConfigurationException { if (parts.length != 3) { throw new ConfigurationException("alarmdecoder:connect", "need hostname and port separated by :"); } m_tcpHostName = parts[1]; try { m_tcpPort = Integer.parseInt(parts[2]); } catch (NumberFormatException e) { throw new ConfigurationException("alarmdecoder:connect", "tcp port not numeric!"); } logger.debug("got tcp configuration: {}:{}", m_tcpHostName, m_tcpPort); } /** * Parses and stores the serial configuration string of the binding configuration. * Expected form is serial@portspeed:devicename, where @portspeed is optional. * * @param parts config string, split on ':' * @throws ConfigurationException */ private void parseSerialConfig(String[] parts) throws ConfigurationException { if (parts.length != 2) { throw new ConfigurationException("alarmdecoder:connect", "serial device name cannot have :"); } m_serialDeviceName = parts[1]; String[] p = parts[0].split("@"); // split again if (p.length == 2) { // an optional port speed is provided try { m_portSpeed = Integer.parseInt(p[1]); } catch (NumberFormatException e) { throw new ConfigurationException("alarmdecoder:connect", "serial port speed must be integer"); } } logger.debug("serial port configuration: speed: {} device: {}", m_portSpeed, m_serialDeviceName); } private void updateSerialProperties(String devName) { /* * By default, RXTX searches only devices /dev/ttyS* and * /dev/ttyUSB*, and will therefore not find devices that * have been symlinked. Adding them however is tricky, see below. */ // first go through the port identifiers to find any that are not in // "gnu.io.rxtx.SerialPorts" ArrayList<String> allPorts = new ArrayList<String>(); @SuppressWarnings("rawtypes") Enumeration portList = CommPortIdentifier.getPortIdentifiers(); while (portList.hasMoreElements()) { CommPortIdentifier id = (CommPortIdentifier) portList.nextElement(); if (id.getPortType() == CommPortIdentifier.PORT_SERIAL) { allPorts.add(id.getName()); } } logger.trace("ports found from identifiers: {}", StringUtils.join(allPorts, ":")); // now add our port so it's in the list if (!allPorts.contains(devName)) { allPorts.add(devName); } // add any that are already in "gnu.io.rxtx.SerialPorts" // so we don't accidentally overwrite some of those ports String ports = System.getProperty("gnu.io.rxtx.SerialPorts"); if (ports != null) { ArrayList<String> propPorts = new ArrayList<String>(Arrays.asList(ports.split(":"))); for (String p : propPorts) { if (!allPorts.contains(p)) { allPorts.add(p); } } } String finalPorts = StringUtils.join(allPorts, ":"); logger.trace("final port list: {}", finalPorts); // Finally overwrite the "gnu.io.rxtx.SerialPorts" System property. // Note: calling setProperty() is not threadsafe. All bindings run in // the same address space, System.setProperty() is globally visible // to all bindings. // This means if multiple bindings use the serial port there is a // race condition where two bindings could be changing the properties // at the same time. System.setProperty("gnu.io.rxtx.SerialPorts", finalPorts); } @SuppressWarnings("rawtypes") @Override public void updated(Dictionary config) throws ConfigurationException { if (config == null) { throw new ConfigurationException("alarmdecoder:connect", "no config!"); } logger.debug("config updated!"); try { m_connectString = (String) config.get("connect"); if (m_connectString == null) { throw new ConfigurationException("alarmdecoder:connect", "no connect config in openhab.cfg!"); } String[] parts = m_connectString.split(":"); if (parts.length < 2) { throw new ConfigurationException("alarmdecoder:connect", "missing :, check openhab.cfg!"); } if (parts[0].equals("tcp")) { parseTcpConfig(parts); } else if (parts[0].startsWith("serial")) { parseSerialConfig(parts); } else { throw new ConfigurationException("alarmdecoder:connect", "invalid parameter " + parts[0]); } String reconn = (String) config.get("reconnect"); if (reconn != null && reconn.trim().length() > 0) { refreshInterval = Long.parseLong(reconn); } String acceptCommands = (String) config.get("send_commands_and_compromise_security"); if (acceptCommands != null && acceptCommands.equalsIgnoreCase("true")) { logger.info("accepting commands!"); m_acceptCommands = true; } setProperlyConfigured(true); } catch (ConfigurationException e) { logger.error("configuration error: {} ", e.getMessage(), e); throw e; } } @Override protected void internalReceiveCommand(String itemName, Command command) { if (!m_acceptCommands) { logger.warn("sending commands is disabled, enable it in openhab.cfg!"); return; } String param = "INVALID"; if (command instanceof OnOffType) { OnOffType cmd = (OnOffType) command; param = cmd.equals(OnOffType.ON) ? "ON" : "OFF"; } else if (command instanceof DecimalType) { param = ((DecimalType) command).toString(); } else { logger.error("item {} only accepts DecimalType and OnOffType", itemName); return; } try { ArrayList<AlarmDecoderBindingConfig> bcl = getItems(itemName); for (AlarmDecoderBindingConfig bc : bcl) { String sendStr = bc.getParameters().get(param); if (sendStr == null) { logger.error("{} has no mapping for command {}!", itemName, param); } else { String s = sendStr.replace("POUND", "#"); m_writer.write(s); m_writer.flush(); } } } catch (IOException e) { logger.error("write to serial port failed: ", e); } } private synchronized void connect() { try { disconnect(); // make sure we have disconnected markAllItemsUnupdated(); if (m_tcpHostName != null && m_tcpPort > 0) { m_socket = new Socket(m_tcpHostName, m_tcpPort); m_reader = new BufferedReader(new InputStreamReader(m_socket.getInputStream())); m_writer = new BufferedWriter(new OutputStreamWriter(m_socket.getOutputStream())); logger.info("connected to {}:{}", m_tcpHostName, m_tcpPort); startMsgReader(); } else if (this.m_serialDeviceName != null) { /* * by default, RXTX searches only devices /dev/ttyS* and * /dev/ttyUSB*, and will so not find symlinks. The * setProperty() call below helps */ updateSerialProperties(m_serialDeviceName); CommPortIdentifier ci = CommPortIdentifier.getPortIdentifier(m_serialDeviceName); CommPort cp = ci.open("openhabalarmdecoder", 10000); if (cp == null) { throw new IllegalStateException("cannot open serial port!"); } if (cp instanceof SerialPort) { m_port = (SerialPort) cp; } else { throw new IllegalStateException("unknown port type"); } m_port.setSerialPortParams(m_portSpeed, SerialPort.DATABITS_8, SerialPort.STOPBITS_1, SerialPort.PARITY_NONE); m_port.setFlowControlMode(SerialPort.FLOWCONTROL_RTSCTS_IN | SerialPort.FLOWCONTROL_RTSCTS_OUT); m_port.disableReceiveFraming(); m_port.disableReceiveThreshold(); m_reader = new BufferedReader(new InputStreamReader(m_port.getInputStream())); m_writer = new BufferedWriter(new OutputStreamWriter(m_port.getOutputStream())); logger.info("connected to serial port: {}", m_serialDeviceName); startMsgReader(); } else { logger.warn("alarmdecoder hostname or port not configured!"); } } catch (PortInUseException e) { logger.error("cannot open serial port: {}, it is in use!", m_serialDeviceName); } catch (UnsupportedCommOperationException e) { logger.error("got unsupported operation {} on port {}", e.getMessage(), m_serialDeviceName); } catch (NoSuchPortException e) { logger.error("got no such port for {}", m_serialDeviceName); } catch (IllegalStateException e) { logger.error("got unknown port type for {}", m_serialDeviceName); } catch (UnknownHostException e) { logger.error("unknown host name :{}: ", m_tcpHostName, e); } catch (IOException e) { logger.error("cannot open connection to {}", m_connectString); } } private void startMsgReader() { m_msgReader = new MsgReader(); m_thread = new Thread(m_msgReader); m_thread.start(); } private synchronized void disconnect() { stopThread(); if (m_socket != null) { try { m_socket.close(); } catch (IOException e) { logger.error("error when closing socket ", e); } m_socket = null; } if (m_port != null) { m_port.close(); m_port = null; } } private void markAllItemsUnupdated() { logger.debug("marking all items as unknown"); for (AlarmDecoderBindingProvider provider : providers) { Collection<String> items = provider.getItemNames(); logger.debug("unupdated items found: {}", items.size()); for (Iterator<String> item = items.iterator(); item.hasNext();) { String s = item.next(); AlarmDecoderBindingConfig c = provider.getBindingConfig(s); synchronized (m_unupdatedItems) { m_unupdatedItems.put(s, c); } } } } private void stopThread() { if (m_msgReader != null) { m_msgReader.stopRunning(); m_msgReader = null; } if (m_thread != null) { try { // wait for thread to stop m_thread.interrupt(); m_thread.join(); } catch (InterruptedException e) { // do nothing } m_thread = null; } } class MsgReader implements Runnable { private boolean m_keepRunning = true; @Override public void run() { logger.debug("msg reader thread started"); String msg; try { while ((msg = m_reader.readLine()) != null && m_keepRunning) { logger.debug("got msg: {}", msg); ADMsgType mt = s_getMsgType(msg); try { switch (mt) { case KPM: parseKeypadMessage(msg); break; case REL: case EXP: parseRelayOrExpanderMessage(mt, msg); break; case RFX: parseRFMessage(msg); break; case LRR: parseLRRMessage(msg); break; case INVALID: default: break; } } catch (MessageParseException e) { logger.error("{} while parsing message {}", e.getMessage(), msg); } } if (msg == null) { logger.error("null read from input stream!"); } } catch (IOException e) { logger.error("I/O error while reading from stream: {}", e.getMessage()); disconnect(); } logger.debug("msg reader thread exited"); } public void stopRunning() { m_keepRunning = false; } } private void parseKeypadMessage(String msg) throws MessageParseException { List<String> parts = splitMsg(msg); if (parts.size() != 4) { throw new MessageParseException("got invalid keypad msg"); } if (parts.get(0).length() != 22) { throw new MessageParseException("bad keypad status length : " + parts.get(0).length()); } try { int numeric = 0; try { numeric = Integer.parseInt(parts.get(1)); } catch (NumberFormatException e) { numeric = Integer.parseInt(parts.get(1), 16); } int upper = Integer.parseInt(parts.get(0).substring(1, 6), 2); int nbeeps = Integer.parseInt(parts.get(0).substring(6, 7)); int lower = Integer.parseInt(parts.get(0).substring(7, 17), 2); int status = ((upper & 0x1F) << 13) | ((nbeeps & 0x3) << 10) | lower; ArrayList<AlarmDecoderBindingConfig> bcl = getItems(ADMsgType.KPM, null, null); for (AlarmDecoderBindingConfig c : bcl) { if (c.hasFeature("zone")) { updateItem(c, new DecimalType(numeric)); } else if (c.hasFeature("text")) { updateItem(c, new StringType(parts.get(3))); } else if (c.hasFeature("beeps")) { updateItem(c, new DecimalType(nbeeps)); } else if (c.hasFeature("status")) { int bit = c.getIntParameter("bit", 0, 17, -1); if (bit >= 0) { // only pick a single bit int v = (status >> bit) & 0x1; updateItem(c, new DecimalType(v)); } else { // pick all bits updateItem(c, new DecimalType(status)); } } else if (c.hasFeature("contact")) { int bit = c.getIntParameter("bit", 0, 17, -1); if (bit >= 0) { // only pick a single bit int v = (status >> bit) & 0x1; updateItem(c, (v == 0) ? OpenClosedType.CLOSED : OpenClosedType.OPEN); } else { // pick all bits logger.warn("ignoring item {}: it has contact without bit field", c.getItemName()); } } } if ((status & (1 << 17)) != 0) { // the panel is clear, so we can assume that all contacts that we // have not heard from are open setUnupdatedItemsToDefault(); } } catch (NumberFormatException e) { throw new MessageParseException("keypad msg contains invalid number: " + e.getMessage()); } } private List<String> splitMsg(String msg) { List<String> l = new ArrayList<String>(); Pattern regex = Pattern.compile("[^\\,\"]+|\"[^\"]*\""); Matcher regexMatcher = regex.matcher(msg); while (regexMatcher.find()) { l.add(regexMatcher.group()); } return l; } protected void addBindingProvider(AlarmDecoderBindingProvider bindingProvider) { super.addBindingProvider(bindingProvider); } protected void removeBindingProvider(AlarmDecoderBindingProvider bindingProvider) { super.removeBindingProvider(bindingProvider); } @Override public void bindingChanged(BindingProvider provider, String itemName) { super.bindingChanged(provider, itemName); logger.trace("binding changed for {}", itemName); AlarmDecoderBindingConfig c = ((AlarmDecoderBindingProvider) provider).getBindingConfig(itemName); // careful, the config reference could be a null pointer! synchronized (m_unupdatedItems) { m_unupdatedItems.put(itemName, c); } } /** * Since there is no way to poll, all items of unknown status are * simply assumed to be in the default (neutral) state. This method * is called when there are reasons to assume that there are no faults, * for instance because the alarm panel is in state READY. */ private void setUnupdatedItemsToDefault() { logger.trace("setting {} unupdated items to default", m_unupdatedItems.size()); synchronized (m_unupdatedItems) { while (!m_unupdatedItems.isEmpty()) { // cannot use the config in the hash map, since it is null String itemName = m_unupdatedItems.keySet().iterator().next(); ArrayList<AlarmDecoderBindingConfig> al = getItems(itemName); for (AlarmDecoderBindingConfig bc : al) { switch (bc.getMsgType()) { case RFX: if (bc.hasFeature("data")) { updateItem(bc, new DecimalType(0)); } else if (bc.hasFeature("contact")) { updateItem(bc, OpenClosedType.CLOSED); } break; case EXP: case REL: updateItem(bc, OpenClosedType.CLOSED); break; case LRR: updateItem(bc, new StringType("")); break; case INVALID: default: m_unupdatedItems.remove(itemName); break; } } } m_unupdatedItems.clear(); } } /** * The relay and expander messages have identical format * * @param mt message type of incoming message * @param msg string containing incoming message * @throws MessageParseException */ private void parseRelayOrExpanderMessage(ADMsgType mt, String msg) throws MessageParseException { String parts[] = splitMessage(msg); if (parts.length != 3) { throw new MessageParseException("need 3 comma separated fields in msg"); } String addr = parts[0] + "," + parts[1]; try { int numeric = Integer.parseInt(parts[2]); if ((numeric & ~0x1) != 0) { throw new MessageParseException("zone status should only be 0 or 1"); } ArrayList<AlarmDecoderBindingConfig> bcl = getItems(mt, addr, "contact"); for (AlarmDecoderBindingConfig c : bcl) { updateItem(c, numeric == 0 ? OpenClosedType.CLOSED : OpenClosedType.OPEN); } } catch (NumberFormatException e) { throw new MessageParseException("msg contains invalid state number" + e.getMessage()); } } private void parseRFMessage(String msg) throws MessageParseException { String parts[] = splitMessage(msg); if (parts.length != 2) { throw new MessageParseException("need 2 comma separated fields in msg"); } try { int numeric = Integer.parseInt(parts[1], 16); ArrayList<AlarmDecoderBindingConfig> bcl = getItems(ADMsgType.RFX, parts[0], null); for (AlarmDecoderBindingConfig c : bcl) { if (c.hasFeature("data")) { int bit = c.getIntParameter("bit", 0, 7, -1); // apply bitmask if requested, else publish raw number int v = (bit == -1) ? numeric : ((numeric >> bit) & 0x00000001); updateItem(c, new DecimalType(v)); } else if (c.hasFeature("contact")) { // if no loop indicator bitmask is set, default to 0x80 int bit = c.getIntParameter("bitmask", 0, 255, 0x80); int v = numeric & bit; updateItem(c, v == 0 ? OpenClosedType.CLOSED : OpenClosedType.OPEN); } } } catch (NumberFormatException e) { throw new MessageParseException("msg contains invalid state number: " + e.getMessage()); } } private void parseLRRMessage(String msg) throws MessageParseException { String parts[] = splitMessage(msg); if (parts.length != 3) { throw new MessageParseException("need 3 comma separated fields in msg"); } ArrayList<AlarmDecoderBindingConfig> bcl = getItems(ADMsgType.LRR, null, null); for (AlarmDecoderBindingConfig c : bcl) { updateItem(c, new StringType(String.join(",", parts))); } } private String[] splitMessage(String msg) throws MessageParseException { String parts[] = msg.split(":"); if (parts.length != 2) { throw new MessageParseException("msg must have exactly one colon"); } return (parts[1].split(",")); } /** * Updates item on the openhab bus * * @param bc binding config * @param state new state of item */ private void updateItem(AlarmDecoderBindingConfig bc, State state) { synchronized (m_unupdatedItems) { m_unupdatedItems.remove(bc.getItemName()); if (!bc.getState().equals(state)) { if (bc.getMsgType() != ADMsgType.KPM) { logger.debug("updating item: {} to state {}", bc.getItemName(), state); } else { logger.trace("updating item: {} to state {}", bc.getItemName(), state); } eventPublisher.postUpdate(bc.getItemName(), state); bc.setState(state); } } } /** * Finds all items that refer to a given message type, address, and feature * * @param mt message type (or null for all messages) * @param addr address to match (or all addresses if null) * @param feature feature to match (or all features if null) * @return array list of all messages */ private ArrayList<AlarmDecoderBindingConfig> getItems(ADMsgType mt, String addr, String feature) { ArrayList<AlarmDecoderBindingConfig> al = new ArrayList<AlarmDecoderBindingConfig>(); for (AlarmDecoderBindingProvider bp : providers) { al.addAll(bp.getConfigurations(mt, addr, feature)); } return al; } /** * Find binding configurations for a given item * * @param itemName name of item to look for * @return array with binding configurations */ private ArrayList<AlarmDecoderBindingConfig> getItems(String itemName) { ArrayList<AlarmDecoderBindingConfig> al = new ArrayList<AlarmDecoderBindingConfig>(); for (AlarmDecoderBindingProvider bp : providers) { al.add(bp.getBindingConfig(itemName)); } return al; } /** * Extract message type from message * * @param s message string * @return message type */ private static ADMsgType s_getMsgType(String s) { if (s == null || s.length() < 4) { return ADMsgType.INVALID; } if (s.startsWith("[")) { return ADMsgType.KPM; } ADMsgType mt = s_startToMsgType.get(s.substring(0, 4)); if (mt == null) { mt = ADMsgType.INVALID; } return mt; } static { s_startToMsgType.put("!REL", ADMsgType.REL); s_startToMsgType.put("!SER", ADMsgType.INVALID); s_startToMsgType.put("!RFX", ADMsgType.RFX); s_startToMsgType.put("!EXP", ADMsgType.EXP); s_startToMsgType.put("!LRR", ADMsgType.LRR); } /** * custom exception for cleaner error handling */ private static class MessageParseException extends Exception { private static final long serialVersionUID = 1L; public MessageParseException(String msg) { super(msg); } } }