UNPKG

knx

Version:

KNXnet/IP protocol implementation for Node(>=6.x)

650 lines (624 loc) 21.7 kB
/** * knx.js - a KNX protocol stack in pure Javascript * (C) 2016-2018 Elias Karakoulakis */ const os = require('os'); const util = require('util'); const ipaddr = require('ipaddr.js'); const machina = require('machina'); const KnxConstants = require('./KnxConstants.js'); const IpRoutingConnection = require('./IpRoutingConnection.js'); const IpTunnelingConnection = require('./IpTunnelingConnection.js'); const KnxLog = require('./KnxLog.js'); module.exports = machina.Fsm.extend({ initialize(options) { this.options = options || {}; // initialise the log driver - to set the loglevel this.log = KnxLog.get(options); // set the local IP endpoint this.localAddress = null; this.ThreeLevelGroupAddressing = true; // reconnection cycle counter this.reconnection_cycles = 0; // a cache of recently sent requests this.sentTunnRequests = {}; this.useTunneling = options.forceTunneling || false; this.remoteEndpoint = { addrstring: options.ipAddr || '224.0.23.12', addr: ipaddr.parse(options.ipAddr || '224.0.23.12'), port: options.ipPort || 3671, }; const range = this.remoteEndpoint.addr.range(); this.localEchoInTunneling = typeof options.localEchoInTunneling !== 'undefined' ? options.localEchoInTunneling : false; // 14=73/2020 Supergiovane (local echo of emitEvent if in tunneling mode) this.log.debug( 'initializing %s connection to %s', range, this.remoteEndpoint.addrstring ); switch (range) { case 'multicast': if (this.localEchoInTunneling) { this.localEchoInTunneling = false; this.log.debug( 'localEchoInTunneling: true but DISABLED because i am on multicast' ); } // 14/03/2020 Supergiovane: if multicast, disable the localEchoInTunneling, because there is already an echo IpRoutingConnection(this); break; case 'unicast': case 'private': case 'loopback': this.useTunneling = true; IpTunnelingConnection(this); break; default: throw util.format( 'IP address % (%s) cannot be used for KNX', options.ipAddr, range ); } }, namespace: 'knxnet', initialState: 'uninitialized', states: { uninitialized: { ['*']() { this.transition('connecting'); }, }, jumptoconnecting: { _onEnter() { this.transition('connecting'); }, }, connecting: { _onEnter() { // tell listeners that we disconnected // putting this here will result in a correct state for our listeners this.emit('disconnected'); this.log.debug(util.format('useTunneling=%j', this.useTunneling)); if (this.useTunneling) { let connection_attempts = 0; if (!this.localAddress) throw 'Not bound to an IPv4 non-loopback interface'; this.log.debug( util.format('Connecting via %s...', this.localAddress) ); // we retry 3 times, then restart the whole cycle using a slower and slower rate (max delay is 5 minutes) this.connecttimer = setInterval(() => { connection_attempts += 1; if (connection_attempts >= 3) { clearInterval(this.connecttimer); // quite a few KNXnet/IP devices drop any tunneling packets received via multicast if (this.remoteEndpoint.addr.range() == 'multicast') { this.log.warn( 'connection timed out, falling back to pure routing mode...' ); this.usingMulticastTunneling = true; this.transition('connected'); } else { // we restart the connection cycle with a growing delay (max 5 minutes) this.reconnection_cycles += 1; const delay = Math.min(this.reconnection_cycles * 3, 300); this.log.debug( 'reattempting connection in ' + delay + ' seconds' ); setTimeout( // restart connecting cycle (cannot jump straight to 'connecting' so we use an intermediate state) () => this.transition('jumptoconnecting'), delay * 1000 ); } } else { this.log.warn('connection timed out, retrying...'); this.send( this.prepareDatagram(KnxConstants.SERVICE_TYPE.CONNECT_REQUEST) ); } }, 3000); delete this.channel_id; delete this.conntime; delete this.lastSentTime; // send connect request directly this.send( this.prepareDatagram(KnxConstants.SERVICE_TYPE.CONNECT_REQUEST) ); } else { // no connection sequence needed in pure multicast routing this.transition('connected'); } }, _onExit() { clearInterval(this.connecttimer); }, inbound_CONNECT_RESPONSE(datagram) { this.log.debug(util.format('got connect response')); if ( datagram.hasOwnProperty('connstate') && datagram.connstate.status === KnxConstants.RESPONSECODE.E_NO_MORE_CONNECTIONS ) { try { this.socket.close(); } catch (error) {} this.transition('uninitialized'); this.emit('disconnected'); this.log.debug( 'The KNXnet/IP server rejected the data connection (Maximum connections reached). Waiting 1 minute before retrying...' ); setTimeout(() => { this.Connect(); }, 60000); } else { // store channel ID into the Connection object this.channel_id = datagram.connstate.channel_id; // send connectionstate request directly this.send( this.prepareDatagram( KnxConstants.SERVICE_TYPE.CONNECTIONSTATE_REQUEST ) ); // TODO: handle send err } }, inbound_CONNECTIONSTATE_RESPONSE(datagram) { if (this.useTunneling) { const str = KnxConstants.keyText( 'RESPONSECODE', datagram.connstate.status ); this.log.debug( util.format( 'Got connection state response, connstate: %s, channel ID: %d', str, datagram.connstate.channel_id ) ); this.transition('connected'); } }, ['*'](data) { this.log.debug(util.format('*** deferring Until Transition %j', data)); this.deferUntilTransition('idle'); }, }, connected: { _onEnter() { // Reset connection reattempts cycle counter for next disconnect this.reconnection_cycles = 0; // Reset outgoing sequence counter.. this.seqnum = -1; /* important note: the sequence counter is SEPARATE for incoming and outgoing datagrams. We only keep track of the OUTGOING L_Data.req and we simply acknowledge the incoming datagrams with their own seqnum */ this.lastSentTime = this.conntime = Date.now(); this.log.debug( util.format( '--- Connected in %s mode ---', this.useTunneling ? 'TUNNELING' : 'ROUTING' ) ); this.transition('idle'); this.emit('connected'); }, }, disconnecting: { // TODO: skip on pure routing _onEnter() { if (this.useTunneling) { const aliveFor = this.conntime ? Date.now() - this.conntime : 0; KnxLog.get().debug( '(%s):\tconnection alive for %d seconds', this.compositeState(), aliveFor / 1000 ); this.disconnecttimer = setTimeout(() => { KnxLog.get().debug( '(%s):\tconnection timed out', this.compositeState() ); try { this.socket.close(); } catch (error) {} this.transition('uninitialized'); this.emit('disconnected'); }, 3000); // this.send( this.prepareDatagram(KnxConstants.SERVICE_TYPE.DISCONNECT_REQUEST), (err) => { // TODO: handle send err KnxLog.get().debug( '(%s):\tsent DISCONNECT_REQUEST', this.compositeState() ); } ); } }, _onExit() { clearTimeout(this.disconnecttimer); }, inbound_DISCONNECT_RESPONSE(datagram) { if (this.useTunneling) { KnxLog.get().debug( '(%s):\tgot disconnect response', this.compositeState() ); try { this.socket.close(); } catch (error) {} this.transition('uninitialized'); this.emit('disconnected'); } }, }, idle: { _onEnter() { if (this.useTunneling) { if (this.idletimer == null) { // set one // time out on inactivity... this.idletimer = setTimeout( () => { this.transition('requestingConnState'); clearTimeout(this.idletimer); this.idletimer = null; }, 60000); } } // debuglog the current FSM state plus a custom message KnxLog.get().debug('(%s):\t%s', this.compositeState(), ' zzzz...'); // process any deferred items from the FSM internal queue this.processQueue(); }, _onExit() { //clearTimeout(this.idletimer); }, // while idle we can either... // 1) queue an OUTGOING routing indication... outbound_ROUTING_INDICATION(datagram) { const elapsed = Date.now() - this.lastSentTime; // if no miminum delay set OR the last sent datagram was long ago... if ( !this.options.minimumDelay || elapsed >= this.options.minimumDelay ) { // ... send now this.transition('sendDatagram', datagram); } else { // .. or else, let the FSM handle it later setTimeout( () => this.handle('outbound_ROUTING_INDICATION', datagram), this.minimumDelay - elapsed ); } }, // 2) queue an OUTGOING tunelling request... outbound_TUNNELING_REQUEST(datagram) { if (this.useTunneling) { const elapsed = Date.now() - this.lastSentTime; // if no miminum delay set OR the last sent datagram was long ago... if ( !this.options.minimumDelay || elapsed >= this.options.minimumDelay ) { // ... send now this.transition('sendDatagram', datagram); } else { // .. or else, let the FSM handle it later setTimeout( () => this.handle('outbound_TUNNELING_REQUEST', datagram), this.minimumDelay - elapsed ); } } else { KnxLog.get().debug( "(%s):\tdropping outbound TUNNELING_REQUEST, we're in routing mode", this.compositeState() ); } }, // 3) receive an INBOUND tunneling request INDICATION (L_Data.ind) ['inbound_TUNNELING_REQUEST_L_Data.ind'](datagram) { if (this.useTunneling) { this.transition('recvTunnReqIndication', datagram); } }, /* 4) receive an INBOUND tunneling request CONFIRMATION (L_Data.con) to one of our sent tunnreq's * We don't need to explicitly wait for a L_Data.con confirmation that the datagram has in fact * reached its intended destination. This usually requires setting the 'Sending' flag * in ETS, usually on the 'primary' device that contains the actuator endpoint */ ['inbound_TUNNELING_REQUEST_L_Data.con'](datagram) { if (this.useTunneling) { const confirmed = this.sentTunnRequests[datagram.cemi.dest_addr]; if (confirmed) { delete this.sentTunnRequests[datagram.cemi.dest_addr]; this.emit('confirmed', confirmed); } KnxLog.get().trace( '(%s): %s %s', this.compositeState(), datagram.cemi.dest_addr, confirmed ? 'delivery confirmation (L_Data.con) received' : 'unknown dest addr' ); this.acknowledge(datagram); } }, // 5) receive an INBOUND ROUTING_INDICATION (L_Data.ind) ['inbound_ROUTING_INDICATION_L_Data.ind'](datagram) { this.emitEvent(datagram); }, inbound_DISCONNECT_REQUEST(datagram) { if (this.useTunneling) { this.transition('connecting'); } }, }, // if idle for too long, request connection state from the KNX IP router requestingConnState: { _onEnter() { // added to note sending connectionstate_request KnxLog.get().debug( 'Requesting Connection State'); KnxLog.get().trace( '(%s): Requesting Connection State', this.compositeState() ); this.send( this.prepareDatagram( KnxConstants.SERVICE_TYPE.CONNECTIONSTATE_REQUEST ) ); // TODO: handle send err // this.connstatetimer = setTimeout(() => { const msg = 'timed out waiting for CONNECTIONSTATE_RESPONSE'; KnxLog.get().trace('(%s): %s', this.compositeState(), msg); this.transition('connecting'); this.emit('error', msg); }, 1000); }, _onExit() { clearTimeout(this.connstatetimer); }, inbound_CONNECTIONSTATE_RESPONSE(datagram) { const state = KnxConstants.keyText( 'RESPONSECODE', datagram.connstate.status ); switch (datagram.connstate.status) { case 0: this.transition('idle'); break; default: this.log.debug( util.format( '*** error: %s *** (connstate.code: %d)', state, datagram.connstate.status ) ); this.transition('connecting'); this.emit('error', state); } }, ['*'](data) { this.log.debug( util.format( '*** deferring %s until transition from requestingConnState => idle', data.inputType ) ); this.deferUntilTransition('idle'); }, }, /* * 1) OUTBOUND DATAGRAM (ROUTING_INDICATION or TUNNELING_REQUEST) */ sendDatagram: { _onEnter(datagram) { // send the telegram on the wire this.seqnum += 1; if (this.useTunneling) datagram.tunnstate.seqnum = this.seqnum & 0xff; this.send(datagram, (err) => { if (err) { //console.trace('error sending datagram, going idle'); this.seqnum -= 1; this.transition('idle'); } else { // successfully sent the datagram if (this.useTunneling) this.sentTunnRequests[datagram.cemi.dest_addr] = datagram; this.lastSentTime = Date.now(); this.log.debug( '(%s):\t>>>>>>> successfully sent seqnum: %d', this.compositeState(), this.seqnum ); if (this.useTunneling) { // and then wait for the acknowledgement this.transition('sendTunnReq_waitACK', datagram); } else { this.transition('idle'); } } // 14/03/2020 Supergiovane: In multicast mode, other node-red nodes receives the echo of the telegram sent (the groupaddress_write event). If in tunneling, force the emit of the echo datagram (so other node-red nodes can receive the echo), because in tunneling, there is no echo. // ######################## //if (this.useTunneling) this.sentTunnRequests[datagram.cemi.dest_addr] = datagram; if (this.useTunneling) { this.sentTunnRequests[datagram.cemi.dest_addr] = datagram; if ( typeof this.localEchoInTunneling !== 'undefined' && this.localEchoInTunneling ) { try { this.emitEvent(datagram); this.log.debug( '(%s):\t>>>>>>> localEchoInTunneling: echoing by emitting %d', this.compositeState(), this.seqnum ); } catch (error) { this.log.debug( '(%s):\t>>>>>>> localEchoInTunneling: error echoing by emitting %d ' + error, this.compositeState(), this.seqnum ); } } } // ######################## }); }, ['*'](data) { this.log.debug( util.format( '*** deferring %s until transition sendDatagram => idle', data.inputType ) ); this.deferUntilTransition('idle'); }, }, /* * Wait for tunneling acknowledgement by the IP router; this means the sent UDP packet * reached the IP router and NOT that the datagram reached its final destination */ sendTunnReq_waitACK: { _onEnter(datagram) { //this.log.debug('setting up tunnreq timeout for %j', datagram); this.tunnelingAckTimer = setTimeout(() => { this.log.debug('timed out waiting for TUNNELING_ACK'); // TODO: resend datagram, up to 3 times this.transition('idle'); this.emit('tunnelreqfailed', datagram); }, 2000); }, _onExit() { clearTimeout(this.tunnelingAckTimer); }, inbound_TUNNELING_ACK(datagram) { this.log.debug( util.format( '===== datagram %d acknowledged by IP router', datagram.tunnstate.seqnum ) ); this.transition('idle'); }, ['*'](data) { this.log.debug( util.format( '*** deferring %s until transition sendTunnReq_waitACK => idle', data.inputType ) ); this.deferUntilTransition('idle'); }, }, /* * 2) INBOUND tunneling request (L_Data.ind) - only in tunnelling mode */ recvTunnReqIndication: { _onEnter(datagram) { this.seqnumRecv = datagram.tunnstate.seqnum; this.acknowledge(datagram); this.transition('idle'); this.emitEvent(datagram); }, ['*'](data) { this.log.debug(util.format('*** deferring Until Transition %j', data)); this.deferUntilTransition('idle'); }, }, }, acknowledge(datagram) { const ack = this.prepareDatagram( KnxConstants.SERVICE_TYPE.TUNNELING_ACK, datagram ); /* acknowledge by copying the inbound datagram's sequence counter */ ack.tunnstate.seqnum = datagram.tunnstate.seqnum; this.send(ack, (err) => { // TODO: handle send err }); }, emitEvent(datagram) { // emit events to our beloved subscribers in a multitude of targets // ORDER IS IMPORTANT! const evtName = datagram.cemi.apdu.apci; // 1. // 'event_<dest_addr>', ''GroupValue_Write', src, data this.emit( util.format('event_%s', datagram.cemi.dest_addr), evtName, datagram.cemi.src_addr, datagram.cemi.apdu.data ); // 2. // 'GroupValue_Write_1/2/3', src, data this.emit( util.format('%s_%s', evtName, datagram.cemi.dest_addr), datagram.cemi.src_addr, datagram.cemi.apdu.data ); // 3. // 'GroupValue_Write', src, dest, data this.emit( evtName, datagram.cemi.src_addr, datagram.cemi.dest_addr, datagram.cemi.apdu.data ); // 4. // 'event', 'GroupValue_Write', src, dest, data this.emit( 'event', evtName, datagram.cemi.src_addr, datagram.cemi.dest_addr, datagram.cemi.apdu.data ); }, getLocalAddress() { const candidateInterfaces = this.getIPv4Interfaces(); // if user has declared a desired interface then use it if (this.options && this.options.interface) { const iface = candidateInterfaces[this.options.interface]; if (!iface) throw new Error( 'Interface ' + this.options.interface + ' not found or has no useful IPv4 address!' ); return candidateInterfaces[this.options.interface].address; } // just return the first available IPv4 non-loopback interface const first = Object.values(candidateInterfaces)[0]; if (first) return first.address; // no local IpV4 interfaces? throw 'No valid IPv4 interfaces detected'; }, // get the local address of the IPv4 interface we're going to use getIPv4Interfaces() { const candidateInterfaces = {}; const interfaces = os.networkInterfaces(); for (const [iface, addrs] of Object.entries(interfaces)) { for (const addr of addrs) { if ([4, 'IPv4'].indexOf(addr.family) > -1 && !addr.internal) { this.log.trace( util.format('candidate interface: %s (%j)', iface, addr) ); candidateInterfaces[iface] = addr; } } } return candidateInterfaces; }, });