uhppoted
Version:
NodeJS module wrapper for the interface to UHPPOTE TCP/IP Wiegand Access Controllers
561 lines (487 loc) • 15.4 kB
JavaScript
/**
* Implements the functions required to send and receive UHPPOTE controller messages.
*
* @module driver
* @private
*/
const codec = require('./codec.js')
const errors = require('./errors.js')
const ipx = require('./ipx.js')
const dgram = require('dgram')
const net = require('net')
const os = require('os')
const opts = { type: 'udp4', reuseAddr: true }
module.exports = {
/**
* Executes a 'get' command to retrieve information from a UHPPOTE access controller.
* 'get' and 'set' are functionally identical but are defined separately for
* semantic clarity.
*
* @param {object} ctx Configuration, logger and debug flags
* @param {number} deviceId The serial number for the target access controller
* @param {byte} op Operation code from 'opcode' module
* @param {object} request Operation parameters for use by codec.encode
* @param {string} dest Optional controller IPv4 address. Defaults to UDP broadcast.
* @param {string} protocol Optional connection protocol ('udp' or 'tcp'). Defaults to
* 'udp' unless 'tcp'
*
* @param {object} Decoded reply containing the received information
*/
get: async function (
ctx,
deviceId,
op,
request,
dest = null,
protocol = 'udp',
) {
const c = context(deviceId, ctx.config, ctx.logger, ctx.locale)
const receiver = receiveAny(c.timeout, ctx.locale)
const decode = function (reply) {
if (reply) {
const response = codec.decode(reply)
if (response && response.deviceId === c.deviceId) {
return response
}
}
throw errors.NoReplyFromDevice(deviceId, ctx.locale)
}
if (dest != null && `${dest}` !== '' && protocol === 'tcp') {
return tcp(c, dest, op, request, receiver).then(decode)
} else {
return udp(c, op, request, receiver).then(decode)
}
},
/**
* Executes a 'set' command to update information on a UHPPOTE access controller.
* 'get' and 'set' are functionally identical but are defined separately for
* semantic clarity.
*
* @param {object} ctx Configuration, internationalisation translation and logger
* @param {number} deviceId The serial number for the target access controller
* @param {byte} op Operation code from 'opcode' module
* @param {object} request Operation parameters for use by codec.encode
* @param {string} dest Optional controller IPv4 address. Defaults to UDP broadcast.
* @param {string} protocol Optional connection protocol ('udp' or 'tcp'). Defaults to
* 'udp' unless 'tcp'
*
* @param {object} Decoded result of the operation
*/
set: async function (
ctx,
deviceId,
op,
request,
dest = null,
protocol = 'udp',
) {
const c = context(deviceId, ctx.config, ctx.logger)
const receiver = receiveAny(c.timeout, ctx.locale)
const decode = function (reply) {
if (reply) {
const response = codec.decode(reply)
if (response && response.deviceId === c.deviceId) {
return response
}
}
throw errors.NoReplyFromDevice(deviceId, ctx.locale)
}
if (dest != null && `${dest}` !== '' && protocol === 'tcp') {
return tcp(c, dest, op, request, receiver).then(decode)
} else {
return udp(c, op, request, receiver).then(decode)
}
},
/**
* Sends a command to update information on a UHPPOTE access controller without
* expecting a reply. Used solely by the 'set-ip' node - the UHPPOTE access controller
* does not reply to the set IP command.
*
* @param {object} ctx Configuration, internationalisation translation and logger
* @param {number} deviceId The serial number for the target access controller
* @param {byte} op Operation code from 'opcode' module
* @param {object} request Operation parameters for use by codec.encode
* @param {string} dest Optional controller IPv4 address. Defaults to UDP broadcast.
* @param {string} protocol Optional connection protocol ('udp' or 'tcp'). Defaults to
* 'udp' unless 'tcp'
*/
send: async function (
ctx,
deviceId,
op,
request,
dest = null,
protocol = 'udp',
) {
const c = context(deviceId, ctx.config, ctx.logger)
const receiver = new Promise((resolve) => {
resolve()
})
const decode = function (_reply) {
return {}
}
receiver.received = (_message) => {}
receiver.cancel = () => {}
if (dest != null && `${dest}` !== '' && protocol === 'tcp') {
return tcp(c, dest, op, request, receiver).then(decode)
} else {
return udp(c, op, request, receiver).then(decode)
}
},
/**
* Broadcasts a command to retrieve information from all responding UHPPOTE access
* controllers. In this implementation it is used exclusively by the 'get-devices'
* node.
*
* It differs from 'get' in that it waits for a timeout before returning an array of
* received responses rather than returning the first received response. It also
* explicity issues a UDP broadcast message - 'get' will issue a UDP 'sendto' if
* possible.
*
* @param {object} ctx Configuration, internationalisation translation and logger
* @param {byte} op Operation code from 'opcode' module
* @param {object} request Operation parameters for use by codec.encode
*
* @param {array} Array of Javascript objects from codec.decode containing the decoded
* received responses.
*/
broadcast: async function (ctx, op, request) {
const c = context(0, ctx.config, ctx.logger)
const replies = []
const receiver = new Promise((resolve) => {
setTimeout(() => {
resolve(replies)
}, c.timeout)
})
const decode = function (replies) {
if (replies) {
return replies.map((m) => {
const response = codec.decode(m)
if (response) {
return response
}
throw errors.InvalidBroadcastReply(ctx.locale)
})
}
throw errors.NoReplyToBroadcast(ctx.locale)
}
receiver.received = (message) => {
replies.push(new Uint8Array(message))
}
receiver.cancel = () => {}
return udp(c, op, request, receiver).then(decode)
},
/**
* Establishes a 'listening' UDP connection on the 'listen' port defined in the
* configuration to receive events from UHPPOTE access controllers configured
* to send events to this host:port. Received events are forwarded to the
* supplied handler for dispatch to the application.
*
* @param {object} ctx Configuration, internationalisation translation and logger
* @param {function} handler Function to invoke with received event
*/
listen: function (ctx, handler) {
const c = context(0, ctx.config, ctx.logger)
const sock = dgram.createSocket(opts)
sock.on('error', (err) => {
handler.onerror(err)
})
sock.on('message', (message, rinfo) => {
log(c.debug, 'received', message, rinfo)
const event = codec.decode(message)
if (event) {
handler.received(event)
}
})
sock.bind({
address: c.listen.address,
port: c.listen.port,
})
return sock
},
}
/**
* Sends a UDP command to a UHPPOTE access controller and returns the decoded
* reply, for use by 'get' and 'set'.
*
* @param {object} context Addresses, logger, debug, etc.
* @param {byte} op Operation code from 'opcode' module
* @param {object} request Operation parameters for use by codec.encode
* @param {function} receive Handler for received messages
*
* @return {object} Decoded reply from access controller
*/
async function udp(ctx, op, request, receive) {
const sock = dgram.createSocket(opts)
const rq = codec.encode(op, ctx.deviceId, request)
const onerror = new Promise((resolve, reject) => {
sock.on('error', (err) => {
reject(err)
})
})
const send = new Promise((resolve, reject) => {
sock.on('listening', () => {
if (ctx.forceBroadcast || isBroadcast(ctx.addr.address)) {
sock.setBroadcast(true)
}
sock.send(
new Uint8Array(rq),
0,
64,
ctx.addr.port,
ctx.addr.address,
(err, bytes) => {
if (err) {
reject(err)
} else {
log(ctx.debug, 'sent', rq, ctx.addr)
resolve(bytes)
}
},
)
})
sock.bind({
address: ctx.bind,
port: 0,
})
})
sock.on('message', (message, rinfo) => {
log(ctx.debug, 'received', message, rinfo)
receive.received(new Uint8Array(message))
})
try {
const result = await Promise.race([onerror, Promise.all([receive, send])])
if (result && result.length === 2) {
return result[0]
}
} finally {
sock.close()
receive.cancel()
}
throw errors.NoReply(ctx.locale)
}
/**
* Opens a TCP connection to the destination addres, sends the command and returns the decoded
* reply, for use by 'get' and 'set'.
*
* @param {object} context Addresses, logger, debug, etc.
* @param {object} dest Destination { address,port }
* @param {byte} op Operation code from 'opcode' module
* @param {object} request Operation parameters for use by codec.encode
* @param {function} receive Handler for received messages
*
* @return {object} Decoded reply from access controller
*/
async function tcp(ctx, dest, op, request, receive) {
const sock = new net.Socket()
const rq = codec.encode(op, ctx.deviceId, request)
const onerror = new Promise((resolve, reject) => {
sock.on('error', (err) => {
reject(err)
})
})
const send = new Promise((resolve, reject) => {
sock.on('connect', () => {
sock.write(new Uint8Array(rq), (err) => {
if (err) {
reject(err)
} else {
log(ctx.debug, 'TCP::sent', rq, dest)
resolve()
}
})
})
sock.connect(dest)
sock.on('data', (message) => {
log(ctx.debug, 'TCP::received', message)
receive.received(new Uint8Array(message))
})
})
try {
const result = await Promise.race([onerror, Promise.all([receive, send])])
if (result && result.length === 2) {
return result[0]
}
} finally {
sock.end()
receive.cancel()
}
throw errors.NoReply(ctx.locale)
}
/**
* Utility function to reconcile supplied configuration against the default
* values. Returns a working 'exec' context with valid:
* - UDP bind address:port
* - UDP destination address:port
* - timeout
* - debug enabled
*
* @param {number} device The serial number for the target access controller
* @param {object} config Configuration object supplied to requesting node
* @param {function} logger Log function for sent/received messages
*
* @param {object} Valid working context
*/
function context(device, config, logger, locale) {
const deviceId = Number(device)
let timeout = 5000
let bind = '0.0.0.0'
let dest = '255.255.255.255:60000'
let listen = '0.0.0.0:60001'
let forceBroadcast = false
let debug = false
if (config) {
timeout = config.timeout
bind = config.bind
dest = config.broadcast
listen = config.listen
debug = config.debug
? function (l, m) {
logger(l + '\n' + m)
}
: null
if (config.controllers && config.controllers.has(device)) {
device = config.controllers.get(device)
for (const [k, v] of Object.entries(device)) {
if (k === 'address') {
dest = v
} else if (k === 'forceBroadcast') {
forceBroadcast = v
}
}
}
}
return {
deviceId,
timeout,
bind,
addr: stringToIP(dest),
listen: stringToIP(listen),
forceBroadcast,
locale,
debug,
}
}
/**
* Utility function to write a sent/received UDP message to the log function.
*
* @param {function} debug The log function that will write the formatted message
* @param {string} label 'sent' or 'received'
* @param {uint8array} message 64 byte UDP message
* @param {object} rinfo source/destination IP address and port
*/
function log(debug, label, message, rinfo) {
let description = label
if (rinfo) {
description = `${label} ${rinfo.address}:${rinfo.port}`
}
if (debug) {
if (typeof debug === 'function') {
const pad = ' '.repeat(25)
debug(description, pad + format(message, pad))
} else {
const prefix = ' '.repeat(18)
const pad = ' '.repeat(26)
console.log(
prefix + '[debug] ' + description + '\n' + pad + format(message, pad),
)
}
}
}
/**
* Utility function to format a 64 byte UDP message.
*
* @param {uint8array} message 64 byte UDP message
* @param {string} pad prefix used to align the message to the log entries
*
* @returns {string} Message formatted as a hexadecimal chunk
*/
function format(message, pad) {
return message
.toString('hex')
.replace(/(.{2})/g, '$& ')
.replace(/(.{24})/g, '$& ')
.replace(/(.{50})/g, '$&\n' + pad)
.trimEnd()
}
/**
* Utility function to convert an IP address in host:port format an object with
* address and port.
*
* @param {string} addr IP address in host:port format
*
* @returns {object} Object containing IP address and port as properties
*/
function stringToIP(addr) {
let address = addr
let port = 60000
const re = /^(.*?)(?::([0-9]+))?$/
const match = addr.match(re)
if (match.length > 1 && match[1]) {
address = match[1]
}
if (match.length > 2 && match[2]) {
port = parseInt(match[2], 10)
}
return {
address,
port,
}
}
/**
* Utility function that takes a best guess as to whether an IP address is likely to be
* a broadcast address. It uses the OS interface list, returning 'true' if the address
* matches one of the 'bit flipped' netmasks.
*
* @param {string} addr IP address
*
* @returns {bool} 'true' if the address is a broadcast address. Defaults to 'false'.
*/
function isBroadcast(addr) {
const interfaces = os.networkInterfaces()
for (const v of Object.entries(interfaces)) {
for (const ifs of v[1]) {
if (ifs.family && ifs.family === 'IPv4') {
const broadcastAddr = ipx.broadcastAddr(ifs.address, ifs.netmask)
if (broadcastAddr === addr) {
return true
}
}
}
}
return false
}
/**
* Utility function construct a Promise that can resolves on receiving a single reply. Used by 'get' and 'set'.
*
* @param {number} timeout Timeout (in seconds). Ignored if 'undefined' (e.g. for send() which does not expect
* a reply)
*
* @returns {promise} Constructed Promised with a 'received' function.
*/
function receiveAny(timeout, locale) {
let timer = null
let f = null
const p = new Promise((resolve, reject) => {
f = resolve
if (timeout) {
timer = setTimeout(() => {
reject(errors.Timeout(locale))
}, timeout)
}
})
p.cancel = () => {
if (timer) {
clearTimeout(timer)
}
}
p.received = (message) => {
if (timer) {
clearTimeout(timer)
}
if (f) {
f(message)
}
}
return p
}