lucyclient
Version:
Lucy Node JS Client Library
486 lines (398 loc) • 18.3 kB
JavaScript
const osUtil = require('./OSUtilities');
const con = require('./TCPPacket');
const net = require('net');
const dgram = require('dgram');
const tcpOut = con.TCPOut;
const TCPInPacket = con.TCPIn;
//TODO winston should be a peer dependency ?
//Logging
const winston = require('winston');
winston.loggers.add('lucyLogger');
const logger = winston.loggers.get('lucyLogger');
const loggingFormat = winston.format.printf(info => {
return `${info.timestamp} ${info.level}: ${info.message}`
});
logger.configure({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
loggingFormat
),
transports: [new winston.transports.Console()]
});
//Settings
const MULTICAST_PORT = 65001; //Multicast port udp network discovery request will be send to
const MULTICAST_ADDRESS = '228.250.206.232'; //Multicast ip udp network discovery request will be send to
const tcpRetryCountBeforeReFetch = 15; /*After 15 failed handshake attempts try to retrieve the
*Server routing info via udp*/
//Only counts for currently retrieved logger
//logger.level = 'silly';
//Todo as will all other libraries do we need a watchdog timer for disconnects?
/**
*
*
* @example
* Retrieve server credentials automatically via UDP
* let lucy = new SmartHouse("MyAwesomeDevice",true/false);
* @example
* Supply information yourself
* let lucy = new SmartHouse("MyAwesomeDevice",true/false,'192.168.000.00',65000);
*
* @param deviceName Unique device name used to identify the device at the server. (Has to match at the server)
* @param udp Do we wish to register an udp socket at the server?
* @param hostAddress Host address of the server. If undefined it will be retrieved via udp network discovery
* @param hostPort Host port of the server. If host address is defined this has to be defined as well.
* @constructor
*/
function SmartHouse(deviceName,udp,hostAddress,hostPort){
//Connection state for asynch connection handling
this.DEVICE_NAME = deviceName; //Name of the device used to match to the class on the server
this.currentConnectionState = this.connectionState["DISCONNECTED"]; //
this.currentUDPConnectionState = this.connectionState["DISCONNECTED"];
//NodeJS will split up tcp packets. The callback handler only contains
//partial information therefore we need to remember old data and reconstruct valid packets
this.carryOverData = undefined;
this.client = undefined; //TCP client
this.UDP = udp; //UDP Client
this.udpCallback = undefined; //UDP event handler called when udp data arrives. Set by user
this.hostAddress = hostAddress; //Host IP of the server used to initiate handshake
this.hostPort = hostPort; //Host Port of the server used to initiate handshake
this.eventHandlerMap = {}; //Event handlers mapped to action events submitted by the server
this.udpNetworkDiscovery = hostAddress === undefined; //Was the server address was ever feetched by upd?
this.udpNetworkDiscoverySocket = undefined;
this.tcpConnectionFailCount = -1;
/**
* Start connecting to server process.
* - 1. If enabled and host unknown retrieve host and port via udp network discovery
* - 2. Initiate handshake via tcp
*/
this.connectToServer = function(){
//Don't try to connect if we are not disconnected!
if(this.currentConnectionState !== this.connectionState["DISCONNECTED"]){
logger.debug("Connection state not disconnected. Return");
return;
}
//If host is unknown feetch it.
if(this.udpNetworkDiscovery && this.hostAddress === undefined){
const _this = this;
//We already initiated a socket.
if(this.currentUDPConnectionState === this.connectionState["DISCONNECTED"]){
logger.info("UDP Network Discovery. Host unknown");
//TODO 2 socket declarations
this.udpNetworkDiscoverySocket = dgram.createSocket('udp4');
this.udpNetworkDiscoverySocket.on('message',this.handleUDPNetworkDiscovery.bind(this));
//Once connected send the request packet
this.udpNetworkDiscoverySocket.on('listening',function() {
logger.info("Send request. - Retry every 10 seconds until server responds");
_this.sendUDPDeviceRecoveryRequest();
});
this.currentUDPConnectionState = this.connectionState["CONNECTED"];
}else {
//Resend udp request
this.sendUDPDeviceRecoveryRequest();
}
//If after 2 seconds we did not receive a reasponds retry
setTimeout(function(){
if(this.hostAddress === undefined)
_this.connectToServer();
}
,10000);
}else{
this.establishConnectionToServer();
}
};
/**
* Request the server to send us it's host and server address.
*/
this.sendUDPDeviceRecoveryRequest = function(){
logger.debug("Send UDP request to server");
const requestPacket = new Buffer([0x01]);
this.udpNetworkDiscoverySocket.send(requestPacket,0,1,MULTICAST_PORT,MULTICAST_ADDRESS, function(err){
if(err !== null) {
logger.error("Error sending udp request package " + err);
}
});
};
//TODO we need to retry on error. Set timeout and use on timeout callback. After x failed attempts
//Try to re fetch server url and port if reuquired.
this.establishConnectionToServer = function(){
const _this = this;
this.client = new net.Socket();
this.client.connect(this.hostPort,this.hostAddress,this.performHandshake.bind(this));
this.client.on('error', function(ex){
if(ex.syscall === 'connect' && ex.code === 'ECONNREFUSED'){
logger.debug("Host server not reachable");
//Reconnection attempt will be handled by client.on.close();
}else{
logger.error(ex);
}
});
this.client.on('data',this.handleTCPData.bind(this));
this.client.on('close', function() {
logger.info('Socket closed. Try reconnecting in 10 seconds');
//Server dropped the connection. Attempt to reconnect
_this.currentConnectionState = _this.connectionState["DISCONNECTED"];
_this.tcpConnectionFailCount++;
//If we failed to connect more than x times and network discovery is enabled refetch
if(_this.tcpConnectionFailCount > tcpRetryCountBeforeReFetch
&& _this.udpNetworkDiscovery){
setTimeout(function(){
_this.hostAddress = undefined;
_this.connectToServer();
_this.tcpConnectionFailCount = -1;
},10000);
}else{
setTimeout(function(){
//Retry
_this.establishConnectionToServer();
},10000);
}
});
this.client.on('timeout', function() {
//Close the socket and reopen it.
logger.warn("timeout");
});
};
this.performHandshake = function(){
logger.debug('Init handshake');
// Welcome flag name length as short user agent length as short = 5 bytes
const buf1 = Buffer.alloc(5);
logger.silly("Device Name: " + this.DEVICE_NAME);
const userAgent = osUtil.userAgent;
const deviceNameLength = this.DEVICE_NAME.length;
const userAgentLength = userAgent.length;
buf1.writeInt16BE(deviceNameLength,1);
buf1.writeInt16BE(userAgentLength,3);
logger.silly(buf1.toString('hex'));
this.client.write(buf1);
let udpBuffer;
let buf2 = Buffer.allocUnsafe(deviceNameLength);
const bufUserAgent = Buffer.allocUnsafe(userAgentLength);
buf2.write(this.DEVICE_NAME);
//UDP_FLAG;
if(this.UDP){
udpBuffer = Buffer.from([0x00]);
}else{
udpBuffer = Buffer.from([0x01]);
}
bufUserAgent.write(userAgent);
buf2 = Buffer.concat([buf2,udpBuffer,bufUserAgent]);
this.client.write(buf2);
//Done
};
this.handleTCPData = function(data) {
logger.silly("TCP data received: " + data.toString('hex'));
if(this.currentConnectionState !== this.connectionState["CONNECTED"]){
this.handleHandshakeResponse(data);
}else{
//Handle normal packet
//1. Check packet validity
//If we have fragmented data from last time append out buffer
if(this.carryOverData !== undefined){
logger.silly("Carry Over: " + this.carryOverData.toString('hex'));
logger.silly("Buffer dump: " + data.toString('hex'));
data = Buffer.concat([this.carryOverData,data]);
this.carryOverData = undefined;
logger.silly("Reconsructed packet: " + data.toString('hex'));
}
let offset = 0;
let flagOffset;
for(flagOffset = 0; flagOffset < 3;){
if(offset >= data.length){
break;
}
if(data[offset] === beginRequestFlag[flagOffset]){
logger.silly("Request flag match: ");
logger.silly(data[offset] + " " + beginRequestFlag[flagOffset]);
flagOffset++;
}else{
logger.silly(data[offset] + " " + beginRequestFlag[flagOffset]);
flagOffset = 0;
logger.silly("Skip bytes");
}
offset++;
}
//TODO reorder
if(flagOffset === 3){
logger.silly("Begin request flag sucessfully parsed");
}else{
logger.silly("Begin request flag failed " + flagOffset);
return;
}
//Disregard invalid data
//Copy everything starting from valid begin request flag to the buffer which starts at offset-3
//Interesting data = von offset - end
if(offset-3 > 0){
logger.silly("Slice packet offset: " + offset-3 + "\nBefore:");
logger.silly(data.toString('hex'));
data = data.slice(offset-3);
logger.silly("After: " );
logger.silly(data.toString('hex'));
}
if(data.length < this.MIN_VALID_PACKET_LENGTH){
//data got teared apart. We need to merge it with the next package
logger.silly("fragmented package push to temp. " + data.length);
this.carryOverData = data;
return;
}else{
var payloadLength = data.readInt32BE(12);
logger.debug("Data received - Payload length: " + payloadLength);
if(data.length < this.MIN_VALID_PACKET_LENGTH + payloadLength) {
logger.debug("fragmented package push to temp. payload length not reached");
this.carryOverData = data;
return;
}
}
var packet = new TCPInPacket(data, 3);
//Residual data
if(data.length > packet.payloadLength + 16){
this.carryOverData = data.slice(packet.payloadLength + 16,data.length);
}
const actionId = packet.actionId;
if(packet.requestFlag){
//A request packet hand over to event handlers
if (actionId === 0) {
logger.debug("Hearbeat packet received");
this.handleHeartbeat(packet);
} else {
logger.debug("Not a heartbeat packet. ActionId: " + actionId);
//Distribute packet to event handlers
if (this.eventHandlerMap[actionId] !== undefined) {
this.eventHandlerMap[actionId](packet);
}
}
}else{
//A response to a request we have send earlier. Invoke callback function
//TODO
}
}
};
this.handleUDPData = function(msg,rInfo){
//This was bound earlier
if(this.udpCallback !== undefined){
this.udpCallback(msg);
}
};
/**
* Will be called upon requesting the server to advertise it's ip address and port
* @param routingInfo
* @param message
*/
this.handleUDPNetworkDiscovery = function(message,routingInfo){
logger.info("UDP Network Discovery Server Answer Received");
logger.debug("Raw UDP Message: " + message);
const parsedPort = message.readInt32BE(0);
message = message.slice(4);
const parsedHost = message.toString();
logger.info("Host: " + parsedHost + " Parsed Port: " + parsedPort);
this.udpNetworkDiscoverySocket.close(this.connectionState);
this.currentUDPConnectionState = this.connectionState["DISCONNECTED"];
this.currentConnectionState = this.connectionState["DISCONNECTED"];
//We parsed host and port. continue connecting
this.hostAddress = parsedHost;
this.hostPort = parsedPort;
this.establishConnectionToServer();
};
this.handleHandshakeResponse = function(data){
//TODO what is this context?
//Handle handshake error codes
var returnCode = data.readInt32BE(0);
if(this.currentConnectionState === this.connectionState["DISCONNECTED"]){
if(returnCode === ACCEPTED){
this.currentConnectionState = this.connectionState["NAME_SUBMISSION"];
}else if(returnCode === BAD_REQUEST){
logger.error("Malformed request!");
}else {
//Unexpected error code
logger.error("Unexpected error code");
}
}else if(this.currentConnectionState === this.connectionState["NAME_SUBMISSION"]){
if(returnCode === PROTOCOL_SWITCHED){
//Protocol switched
logger.info("Handshake successful");
this.currentConnectionState = this.connectionState["CONNECTED"];
if(this.UDP){
//TODO error
this.udpClient = dgram.createSocket('udp4');
this.udpClient.on('message',this.handleUDPData.bind(this));
this.udpClient.bind();
}
this.tcpConnectionFailCount = -1;
}else if(returnCode === DEVICE_NOT_FOUND){
logger.error("No device on server registerd with suupplied name!");
}else if(returnCode === BAD_REQUEST){
logger.error("Malformed request!");
}else{
//Unexpected error code
logger.error("Unexpected error code");
}
}
};
this.handleHeartbeat = function(packet){
//setTimeout() schedule asynch
const responsePacket = packet.createResponse();
responsePacket.sendDataToServer(this.client);
logger.debug("Heartbeat response send");
};
}
const beginRequestFlag = [ 0x00, 0x01, 0x02 ];
//return codes
const PROTOCOL_SWITCHED = 101;
const ACCEPTED = 202;
const BAD_REQUEST = 400;
const DEVICE_NOT_FOUND = 404;
//Begin flag = 3
//Request flag = 1
//Action id = 4
//UniqueRequestId = 4;
//PayloadLength = 4;
SmartHouse.prototype = {
registerEventCallback : function(id,callback){
if(this.eventHandlerMap.length < id || this.eventHandlerMap[id] !== undefined) {
logger.warn("Override already set event callback with id: " + id);
}
this.eventHandlerMap[id] = callback;
},
registerUDPCallback : function(callback){
if(this.udpCallback !== undefined){
logger.warn("Override already present udp callback");
}
this.udpCallback = callback;
},
sendUDP: function(data){
this.udpClient.send(data,this.hostPort,this.hostAddress);
},
/**
* Create a tcp request packet which will be send to the server
* @param actionId action id the server can use to identify the packet intention
* @param callback callback function which will be called once the server responds to this packet
* undefined if no responds is expected
* @param timeoutCallback function will be called in case of the package not receiving a timeout within the specified
* time interval
* @param timeout timeout in ms nodejs will wait until discarding the callback. This avoid resource leaks
* in case of never handled packages and indicates server failure.
* defaults to 20 seconds
* @returns {TCPOutPacket}
*/
createRequest : function(actionId,callback, timeoutCallback,timeout = 20000){
//Just forward this function call so we can expose it to the user by simply creating a smart house instance
return tcpOut.createRequest(actionId,callback, timeoutCallback,timeout);
},
/**
* Shuts down the smart house client and closes sockets.
* This function does not reset event callbacks.
*/
shutdown : function(){
if(this.client !== undefined){
if(this.currentConnectionState !== this.connectionState["DISCONNECTED"]){
this.client.end();
this.currentConnectionState !== this.connectionState["DISCONNECTED"];
//TODO do we also want to reset event callbacks?
//Also prevent reconnecting.
}
}
},
connectionState : Object.freeze({"NAME_SUBMISSION":0,"CONNECTED":1,"DISCONNECTED":2}),
MIN_VALID_PACKET_LENGTH : 16
};
module.exports = SmartHouse;