/**
* 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.lcn.connection;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
import java.nio.BufferOverflowException;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.Map;
import org.openhab.binding.lcn.common.LcnAddr;
import org.openhab.binding.lcn.common.LcnAddrGrp;
import org.openhab.binding.lcn.common.LcnAddrMod;
import org.openhab.binding.lcn.common.LcnDefs;
import org.openhab.binding.lcn.common.PckGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This class represents a configured connection to LCN-PCHK.
* It uses a {@link SocketChannel} to connect to LCN-PCHK.
* Included logic:
* <ul>
* <li>Reconnection on connection loss
* <li>Segment scan (to detect the local segment)
* <li>Acknowledge handling
* <li>Periodic value requests
* </ul>
* It also caches runtime data about the underlying LCN bus.
*
* @author Tobias J�ttner
*/
public class Connection {
/** Logger for this class. */
private static final Logger logger = LoggerFactory.getLogger(Connection.class);
/** Must be implemented by the owner. */
public interface Callback {
/**
* Gets the NIO selector used for connect and read events.
*
* @return the selector
*/
Selector getSelector();
/**
* Updates all openHAB items associated with the given connection.
*
* @param conn the connection
*/
void updateItems(Connection conn);
/**
* Process input received from the given connection.
*
* @param input the received input
* @param conn the source connection
*/
void onInputReceived(String input, Connection conn);
/**
* Get the sync.-monitor to use when registering new channels with the NIO selector.
*
* @return the sync.-monitor object
*/
Object getChannelRegisterSync();
}
/** Interval between keep-alive packets (keeps the LCN-PCHK connection open). */
private static final long PING_INTERVAL_MSEC = 600000;
/** The connection's settings. Never changed. */
private final ConnectionSettings sets;
/** The callback to the owner. */
private final Callback callback;
/** Indicates async. connecting is in progress. */
private boolean isChannelConnecting;
/** The actual NIO channel. null if disconnected. */
private SocketChannel channel;
/** != 0 if we are currently reconnecting. */
private long reconnectTimestamp;
/** Moment the last ping was sent (0 if disconnected). */
private long lastPingTimeStamp;
/** Counter used in pings ("LCN-PCHK best practice"). */
private int pingCounter;
/** Connection state of the LCN bus (LCN-PK/PKU). */
private boolean isLcnConnected;
/** The local segment id. -1 means "unknown". */
private int localSegId;
/** Status of segment-scan. */
private final RequestStatus statusSegmentScan = new RequestStatus(-1, 3);
/** Holds data read from the {@link #channel} that has not been processed yet. */
private final ByteBuffer readBuffer = ByteBuffer.allocate(1024);
/** Queued data that has to be sent. */
private final LinkedList<SendData> sendQueue = new LinkedList<SendData>();
/** Buffer used in {@link #flush()}. Reused for optimization. */
private final ByteBuffer sendBuffer = ByteBuffer.allocate(1024);
/** Stores information about LCN modules and coordinates status requests. */
private final HashMap<LcnAddrMod, ModInfo> modData = new HashMap<LcnAddrMod, ModInfo>();
/**
* Constructs a clean (disconnected) connection with the given settings.
* This does not start the actual connection process.
*
* @param sets the settings to use for the new connection
* @param callback the callback to the owner
*/
public Connection(ConnectionSettings sets, Callback callback) {
this.sets = sets;
this.callback = callback;
this.clearRuntimeData();
}
/** Clears all runtime data. */
private void clearRuntimeData() {
this.isChannelConnecting = false;
this.channel = null;
this.reconnectTimestamp = 0;
this.lastPingTimeStamp = 0;
this.pingCounter = 0;
this.isLcnConnected = false;
this.localSegId = -1;
this.statusSegmentScan.reset();
this.readBuffer.clear();
this.sendQueue.clear();
this.sendBuffer.clear();
this.modData.clear();
}
/**
* Retrieves the settings for this connection (never changed).
*
* @return the settings
*/
public ConnectionSettings getSets() {
return this.sets;
}
/**
* Checks whether the underlying channel is currently connecting.
*
* @return true if connecting is in progress
*/
boolean isChannelConnecting() {
return this.isChannelConnecting;
}
/**
* Returns the connection state of the underlying channel.
*
* @return the connection state
*/
boolean isChannelConnected() {
return this.channel != null && this.channel.isConnected();
}
/** Called after successful authentication. */
public void onAuthOk() {
// Legacy support for LCN-PCHK 2.2 and earlier:
// There was no explicit "LCN connected" notification after successful authentication.
// Only "LCN disconnected" would be reported immediately. That means "LCN connected" used to be the default.
// Note that LCN-PCHK 2.3 and later will set the connection state a second time.
this.setLcnConnected(true); // Only guessed
}
/**
* Retrieves the current connection state to the LCN bus (LCN-PK/PKU).
*
* @return the LCN connection state
*/
public boolean isLcnConnected() {
return this.isLcnConnected;
}
/**
* Sets the current connection state.
*
* @param isLcnConnected the state
*/
public void setLcnConnected(boolean isLcnConnected) {
if (isLcnConnected) {
if (!this.statusSegmentScan.isActive()) {
this.statusSegmentScan.nextRequestIn(0, System.nanoTime());
}
} else {
// Repeat segment scan on next connect
this.localSegId = -1;
this.statusSegmentScan.reset();
// While we are disconnected we will miss all status messages.
// Clearing our runtime data will give us a fresh start.
this.modData.clear();
}
this.isLcnConnected = isLcnConnected;
}
/**
* Sets the local segment id.
*
* @param localSegId the new local segment id
*/
public void setLocalSegId(int localSegId) {
this.localSegId = localSegId;
this.statusSegmentScan.onResponseReceived();
}
/**
* Translates the given physical address into its logical equivalent.
*
* @param addr the (source) address as received directly from the LCN bus
* @return the translated address (segment 0 will be replaced with the "real" segment id)
*/
public LcnAddrMod physicalToLogical(LcnAddrMod addr) {
return new LcnAddrMod(addr.getSegId() == 0 ? this.localSegId : addr.getSegId(), addr.getModId());
}
/**
* Called whenever an acknowledge is received.
*
* @param addr the source LCN module
* @param code the LCN internal code (-1 = "positive")
*/
public void onAck(LcnAddrMod addr, int code) {
ModInfo info = this.modData.get(addr);
if (info != null) {
info.onAck(code, this, this.sets.getTimeout(), System.nanoTime());
}
}
/**
* Retrieves the completion state.
* Nothing should be sent before this is signaled.
*
* @return true if everything is set-up
*/
public boolean isReady() {
return this.isChannelConnected() && this.isLcnConnected && this.localSegId != -1;
}
/**
* Retrieves cached data for the given LCN module.
* Must be created by calling {@link #updateModuleData}.
*
* @param addr the module's address
* @return the data or null
*/
public ModInfo getModInfo(LcnAddrMod addr) {
return this.modData.get(addr);
}
/**
* Creates and/or returns cached data for the given LCN module.
*
* @param addr the module's address
* @return the data (never null)
*/
public ModInfo updateModuleData(LcnAddrMod addr) {
ModInfo data = this.modData.get(addr);
if (data == null) {
data = new ModInfo(addr);
this.modData.put(addr, data);
}
return data;
}
/**
* Must be called once the connection is established.
*
* @throws ClosedChannelException if channel is not open
*/
private void onConnected() throws ClosedChannelException {
this.isChannelConnecting = false;
this.lastPingTimeStamp = System.nanoTime(); // Start ping timer
synchronized (this.callback.getChannelRegisterSync()) {
this.callback.getSelector().wakeup(); // Wakes up a current or next select
this.channel.register(this.callback.getSelector(), SelectionKey.OP_READ, this);
}
}
/** Begin to connect (async.). */
void beginConnect() {
this.disconnect(); // Be kind
try {
SocketChannel channel = SocketChannel.open();
channel.configureBlocking(false);
synchronized (this.callback.getChannelRegisterSync()) {
this.callback.getSelector().wakeup(); // Wakes up a current or next select
channel.register(this.callback.getSelector(), SelectionKey.OP_CONNECT, this);
}
logger.info(String.format("Connecting channel \"%s\".", channel));
String resolvedAddress = InetAddress.getByName(sets.getAddress()).getHostAddress();
if (channel.connect(new InetSocketAddress(resolvedAddress, sets.getPort()))) {
// Connection was established immediately.
// Otherwise the selector will be used to finish the connection.
this.onConnected();
}
this.channel = channel;
this.isChannelConnecting = true;
} catch (UnknownHostException ex) {
logger.warn(String.format("Unable to resolve host: %s", ex.getMessage()));
} catch (IOException ex) {
logger.warn(String.format("Unable to set up a new SocketChannel: %s", ex.getMessage()));
}
}
/**
* Must be called once the connection is established.
*
* @throws IOException if connection cannot be established
*/
void finishConnect() throws IOException {
if (this.channel.finishConnect()) {
this.onConnected();
}
}
/** Disconnect (sync.). */
void disconnect() {
if (this.channel != null) {
this.channel.keyFor(this.callback.getSelector()).cancel();
this.callback.getSelector().wakeup(); // Unblock a current selection to actually remove the key
try {
logger.debug("...resetting channel...");
this.channel.close();
} catch (IOException e) {
logger.error(String.format("An exception occurred while closing a channel: %s", e.getMessage()));
}
this.clearRuntimeData();
}
}
/**
* Schedules a reconnect attempt after the given time.
* Can be called multiple times without side effects.
*
* @param timeMSec the delay in milliseconds.
*/
void beginReconnect(int timeMSec) {
this.disconnect(); // Be kind
this.reconnectTimestamp = System.nanoTime() + timeMSec * 1000000L;
}
/**
* Reads and processes input from the underlying channel.
* Fragmented input is kept in {@link #readBuffer} and will be processed with the next call.
*
* @throws IOException if connection was closed or a generic channel error occurred
*/
void readAndProcess() throws IOException {
try {
int n;
if ((n = this.channel.read(this.readBuffer)) == -1) {
throw new IOException("Connection was closed by foreign host.");
}
this.readBuffer.flip();
int aPos = this.readBuffer.position(); // 0
String s = new String(this.readBuffer.array(), aPos, n, LcnDefs.LCN_ENCODING);
int pos1 = 0, pos2 = s.indexOf(PckGenerator.TERMINATION, pos1);
while (pos2 != -1) {
this.callback.onInputReceived(s.substring(pos1, pos2), this);
// Seek position in input array
aPos += s.substring(pos1, pos2 + 1).getBytes(LcnDefs.LCN_ENCODING).length;
// Next input
pos1 = pos2 + 1;
pos2 = s.indexOf(PckGenerator.TERMINATION, pos1);
}
this.readBuffer.limit(this.readBuffer.capacity());
this.readBuffer.position(n - aPos); // Keeps fragments for the next call
} catch (UnsupportedEncodingException ex) {
logger.warn(String.format("Unable to decode input from channel \"%s\": %s", this.sets.getId(),
ex.getMessage()));
}
}
/**
* Queues data to be sent to LCN-PCHK.
* Sending will be done the next time {@link #flush()} is called.
*
* @param data the data
*/
void queue(SendData data) {
this.sendQueue.add(data);
}
/**
* Queues plain text to be sent to LCN-PCHK.
* Sending will be done the next time {@link #flush()} is called.
*
* @param plainText the text
*/
public void queue(String plainText) {
this.queue(new SendData.PlainText(plainText));
}
/**
* Queues a PCK command to be sent.
*
* @param addr the target LCN address
* @param wantsAck true to wait for acknowledge on receipt (should be false for group addresses)
* @param data the pure PCK command (without address header)
*/
public void queue(LcnAddr addr, boolean wantsAck, ByteBuffer data) {
if (!addr.isGroup() && wantsAck) {
this.updateModuleData((LcnAddrMod) addr).queuePckCommandWithAck(data, this, this.sets.getTimeout(),
System.nanoTime());
} else {
this.queue(new SendData.PckSendData(addr, wantsAck, data));
}
}
/**
* Queues a PCK command to be sent.
*
* @param addr the target LCN address
* @param wantsAck true to wait for acknowledge on receipt (should be false for group addresses)
* @param pck the pure PCK command (without address header)
*/
public void queue(LcnAddr addr, boolean wantsAck, String pck) {
try {
this.queue(addr, wantsAck, ByteBuffer.wrap(pck.getBytes(LcnDefs.LCN_ENCODING)));
} catch (UnsupportedEncodingException ex) {
logger.error(String.format("Failed to encode PCK command: %s", pck));
}
}
/**
* Writes all queued data.
* Will try to write all data at once to reduce overhead.
*/
void flush() {
if (this.isChannelConnected()) {
// Write send-queue to buffer
try {
this.sendBuffer.clear();
Iterator<SendData> iter = this.sendQueue.iterator();
while (iter.hasNext()) {
try {
if (!iter.next().write(this.sendBuffer, this.localSegId)) {
break;
}
} catch (UnsupportedEncodingException ex) {
}
iter.remove();
}
} catch (BufferOverflowException ex) {
// Not critical. Our buffer is too small to hold all data.
// The rest will be processed in the next flush.
}
// Write buffer to channel
try {
this.sendBuffer.flip();
if (this.channel.write(this.sendBuffer) != this.sendBuffer.limit()) {
logger.warn(String.format("Data loss while writing to channel \"%s\".", this.sets.getAddress()));
}
} catch (IOException ex) {
logger.warn(
String.format("Writing to channel \"%s\" failed: %s", this.sets.getAddress(), ex.getMessage()));
}
}
}
/** Must be called periodically to keep the inner logic active. */
void update() {
long currTime = System.nanoTime();
// Reconnect logic
if (this.reconnectTimestamp != 0 && currTime >= this.reconnectTimestamp) {
this.beginConnect();
this.reconnectTimestamp = 0;
} else {
if (this.isChannelConnected()) {
// Keep-alive / ping logic
if (currTime - this.lastPingTimeStamp > PING_INTERVAL_MSEC * 1000000L) {
this.queue(PckGenerator.ping(++this.pingCounter));
this.lastPingTimeStamp = currTime;
}
// Segment scan logic
if (this.statusSegmentScan.shouldSendNextRequest(this.sets.getTimeout(), currTime)) {
this.queue(new LcnAddrGrp(3, 3), false, PckGenerator.segmentCouplerScan());
this.statusSegmentScan.onRequestSent(currTime);
} else if (this.statusSegmentScan.isFailed(this.sets.getTimeout(), currTime)) {
// Give up. Probably no segments available.
this.setLocalSegId(0);
}
}
// LcnModInfo logic
this.callback.updateItems(this);
if (this.isReady()) {
for (Map.Entry<LcnAddrMod, ModInfo> entry : this.modData.entrySet()) {
entry.getValue().update(this, this.sets.getTimeout(), currTime);
}
}
}
}
}