UNPKG

lorano

Version:

Compact and opinionated LoRa communications library

642 lines (574 loc) 22.1 kB
"use strict"; process.env.UV_THREADPOOL_SIZE = (process.env.UV_THREADPOOL_SIZE || 4) + 3; const stream = require('stream'), crypto = require('crypto'), { transaction } = require('objection'), aw = require('awaitify-stream'), lora_packet = require('lora-packet'), PROTOCOL_VERSION = 2, pkts = { PUSH_DATA: 0, PUSH_ACK: 1, PULL_DATA: 2, PULL_RESP: 3, PULL_ACK: 4, TX_ACK: 5 }; async function wait_for(link, pkt) { while (true) { const data = await link.readAsync(); if (data === null) { return null; } if ((data.length >= 4) && (data[0] === PROTOCOL_VERSION) && (data[3] === pkt)) { return data; } } } /** * Creates a Duplex stream (in object mode) which reads and writes to a LoRa radio * link. * * It can perform over-the-air activation of devices it knows about on behalf * of your application. * * Messages received from the radio link are made available to read from the * Duplex and have the following properties: * * - `nwk_addr (Buffer)` - Unique network address of the sending device. * - `dev_eui (Buffer)` - Unique global device identifier (IEEE EUI64) of the sending device. * - `dev_addr (Buffer)` - Combined identifier of your network (7 most significant bits) and the unique network address of the sending device (i.e. `nwk_addr`, 25 least significant bits). * - `payload (Buffer)` - The data which the sending device sent to your application. * - `msg (Object)` - The raw packet received by {@link https://github.com/davedoesdev/packet_forwarder_shared|packet_forwarder_shared}. The format is described {@link https://github.com/davedoesdev/packet_forwarder_shared/blob/master/PROTOCOL.TXT|here}. * - `packet (Object)` - The decoded packet. Anthony Kirby's excellent {@link https://github.com/anthonykirby/lora-packet|lora-packet} is used to do the decoding. * - `reply (Object)` - This contains nearly everything needed to reply to the message. Your application just has to set the payload. * - `header (Object)` - The metadata required by {@link https://github.com/davedoesdev/packet_forwarder_shared|packet_forwarder_shared} to send the reply. See section 6 of {@link https://github.com/davedoesdev/packet_forwarder_shared/blob/master/PROTOCOL.TXT|here}. * - `encoding (Object)` - The fields required by {@link https://github.com/anthonykirby/lora-packet#fromfieldsdata|lora-packet} to encode the reply. * - `payload (undefined)` - Your application must set this property to a Buffer containing the data you want to send back to the device. * * If your application wants to reply to the message, set `reply.payload` to * the data that should be sent and then write `reply` to the Duplex. * * @param {class} Model - The {@link https://vincit.github.io/objection.js/#models|Objection.js Model class}, or a subclass of it, already {@link https://vincit.github.io/objection.js/#knex|configured for your database using Knex.js}. * @param {stream.Duplex} uplink - {@link https://rawgit-gjgjyaqiln.now.sh/davedoesdev/node-lora-comms/master/docs/index.html#lora-commsuplink|lora-comms uplink stream}. Your application must {@link https://rawgit-gjgjyaqiln.now.sh/davedoesdev/node-lora-comms/master/docs/index.html#lora-commsstart|start} the {@link https://github.com/davedoesdev/node-lora-comms|lora-comms} module first and then retrieve the uplink. * @param {stream.Duplex} downlink - {@link https://rawgit-gjgjyaqiln.now.sh/davedoesdev/node-lora-comms/master/docs/index.html#lora-commsdownlink|lora-comms downlink stream}. Your application must {@link https://rawgit-gjgjyaqiln.now.sh/davedoesdev/node-lora-comms/master/docs/index.html#lora-commsstart|start} the {@link https://github.com/davedoesdev/node-lora-comms|lora-comms} module first and then retrieve the downlink. * @param {Object} options - Configuration options. * @param {Buffer} options.appid - 8-byte identifier of your application in the IEEE EUI64 address space. This is also known as the AppEUI and your OTAA devices must use the same value. * @param {Buffer} options.netid - 3-byte identifier of your network. The 7 least significant bits must be unique for neighbouring or overlapping networks. */ class Link extends stream.Duplex { constructor(Model, uplink, downlink, options) { // options.appid = AppEUI // options.netid = NetId: NwkID (7 LSB), rest chosen by network operator // Private NwkId: 000000 or 000001 super(Object.assign({}, options, { objectMode: true })); this._options = Object.assign( { // RX1 data rate offset. Just subtracts (see below for data rates). // See section 7.1.7 of LoRaWAN spec. RX1DRoffset: 0, // RX2 data rate. Region-specific. EU: // 0 SF12 / 125kHz // 1 SF11 / 125kHz // 2 SF10 / 125kHz // 3 SF9 / 125kHz // 4 SF8 / 125kHz // 5 SF7 / 125kHz // 6 SF7 / 250kHz // 7 FSK: 50kbps // 8-15 RFU RX2DataRate: 0, // RxDelay: // 7-4 RFU // 3-0 Delay in seconds RXDelay: 1, FCntDownMax: 0xffff }, options); this._knex = Model.knex(); this._ABPDevices = class extends Model { static get tableName() { return 'ABPDevices'; }; static get idColumn() { return 'DevAddr'; }; }; this._OTAADevices = class extends Model { static get tableName() { return 'OTAADevices'; }; static get idColumn() { return 'NwkAddr'; }; }; this._OTAAHistory = class extends Model { static get tableName() { return 'OTAAHistory'; }; static get idColumn() { return ['DevEUI', 'DevNonce']; }; }; this._pending = []; this._reading = false; downlink.on('finish', () => { this.end(); }); this._uplink_out = aw.createWriter(uplink); this._downlink_out = aw.createWriter(downlink); let received_first_pull_data = false; const ack = (data, _, cb) => { (async () => { if ((data.length >= 4) && (data[0] === PROTOCOL_VERSION)) { const type = data[3]; if (type === pkts.PUSH_DATA) { await this._uplink_out.writeAsync(Buffer.concat([ data.slice(0, 3), Buffer.from([pkts.PUSH_ACK])])); } else if (type === pkts.PULL_DATA) { await this._downlink_out.writeAsync(Buffer.concat([ data.slice(0, 3), Buffer.from([pkts.PULL_ACK])])); // We want to handle further PULL_DATA messages without // the application having to read from downlink. setImmediate(() => this._downlink_in.stream.read(0)); if (received_first_pull_data) { return cb(); } received_first_pull_data = true; } } cb(null, data); })(); }; this._uplink_in = aw.createReader(uplink.pipe( new stream.Transform({ transform: ack, highWaterMark: 0, objectMode: true }))); this._downlink_in = aw.createReader(downlink.pipe( new stream.Transform({ transform: ack, highWaterMark: 0, objectMode: true }))); (async () => { try { await wait_for(this._downlink_in, pkts.PULL_DATA); this.emit('ready'); } catch (ex) { process.nextTick(() => this.emit('error', ex)); } })(); } _read() { if (this._reading) { return; } this._reading = true; (async () => { try { while (true) { // Process each message we last read let msg; while ((msg = this._pending.shift()) !== undefined) { // Decode message data const data = Buffer.from(msg.data, 'base64'); const decoded = lora_packet.fromWire(data); // OTAA join if (decoded.isJoinRequestMessage()) { await this._join(msg, decoded); } // Data else if (decoded.isDataMessage() && (decoded.getDir() === 'up') && !await this._data(msg, decoded)) { return; } } // Read incoming packet const packet = await wait_for(this._uplink_in, pkts.PUSH_DATA); if (packet === null) { return this.push(null); } // Parse packet data (JSON-encoded by shared pkt forwarder) const payload = JSON.parse(packet.slice(12)); if (payload.rxpk) { this._pending = payload.rxpk; } } } catch (ex) { this.emit('error', ex); } finally { this._reading = false; } })(); } _write(data, _, cb) { (async () => { try { const encoded = data.encoded || await transaction(this._knex, async trx => { const device = await this._device(data.encoding.DevAddr, trx); if (!device) { // we don't know this device throw new Error('unknown device'); } if (!device.NwkSKey || !device.AppSKey) { // can't be abp because abp keys aren't nullable throw new Error('device not joined'); } if (device.FCntDown > this._options.FCntDownMax) { throw new Error('send frame count exceeded'); } await this._patch_device( device, { FCntDown: device.FCntDown + 1 }, trx); return lora_packet.fromFields(Object.assign( { payload: data.payload, FCnt: device.FCntDown }, data.encoding), device.AppSKey, device.NwkSKey); }); const payload = encoded.getPHYPayload(); const header = Buffer.alloc(4); header[0] = PROTOCOL_VERSION; crypto.randomFillSync(header, 1, 2); header[3] = pkts.PULL_RESP; const txpk = Object.assign( { data: payload.toString('base64'), size: payload.length }, data.header); await this._downlink_out.writeAsync(Buffer.concat([ header, Buffer.from(JSON.stringify({txpk: txpk}))])); const tx_ack = await wait_for(this._downlink_in, pkts.TX_ACK); if ((tx_ack !== null) && ((tx_ack[1] !== header[1]) || (tx_ack[2] !== header[2]))) { throw new Error('TX_ACK token mismatch'); } cb(); } catch (ex) { cb(ex); } })(); } _header(msg, delay) { return { tmst: msg.tmst + delay, freq: msg.freq, rfch: 0, // only 0 can transmit modu: msg.modu, datr: msg.datr, codr: msg.codr, ipol: true }; } async _patch_device(device, data, trx) { if (device.NwkAddr) { await this._OTAADevices .query(trx) .patch(data) .where('NwkAddr', device.NwkAddr); } else { await this._ABPDevices .query(trx) .patch(data) .where('DevAddr', device.DevAddr); } } async _join(msg, decoded) { // Check OTAA join request is for this app const buffers = decoded.getBuffers(); if (!buffers.AppEUI.equals(this._options.appid)) { return; } // Check if we know this device const device = await this._deveui_to_otaa_device(buffers.DevEUI); if (!device) { return; } // Verify request if (!lora_packet.verifyMIC(decoded, null, device.AppKey)) { return this.emit('verify_mic_failed', device, decoded); } // Check for replay try { await this._OTAAHistory .query() .insert( { DevEUI: buffers.DevEUI, DevNonce: buffers.DevNonce }); } catch (ex) { return this.emit('join_replay', device, decoded, ex); } // Make AppNonce const app_nonce = crypto.randomBytes(3); // Make NwkSKey const nwk_skey = this._skey(device.AppKey, 0x01, app_nonce, buffers.DevNonce); // Make AppSKey const app_skey = this._skey(device.AppKey, 0x02, app_nonce, buffers.DevNonce); // Make DevAddr const dev_addr = this.nwk_addr_to_dev_addr(device.NwkAddr); // Allow subclass to customise Join Accept message const data = await this._join_data( { msg: msg, packet: decoded, nwk_addr: device.NwkAddr, dev_eui: device.DevEUI, dev_addr: dev_addr, reply: { header: this._header(msg, 5000000), // JOIN_ACCEPT_DELAY1 (5s) encoding: { MType: 'Join Accept', AppNonce: app_nonce, NetID: this._options.netid, DevAddr: dev_addr, // DLSettings: // 7 RFU (reserved) // 6-4 RX1DRoffset // 3-0 RX2 data rate (same as LinkADRReq in spec) DLSettings: (this._options.RX1DRoffset << 4) | this._options.RX2DataRate, RXDelay: this._options.RXDelay // CFList (optional, none given here): // Frequences of channels 4-8 // Each 3 bytes (24 bit, freq in Hz / 100), unused is 0 // Followed by 1 RFU byte } } }); // Encode Join Accept message data.encoded = lora_packet.fromFields( data.encoding, app_skey, nwk_skey, device.AppKey); // Save keys await this._OTAADevices .query() .patch( { NwkSKey: nwk_skey, AppSKey: app_skey, FCntUp: 0, FCntDown: 0 }) .where('NwkAddr', device.NwkAddr); // Send Join Accept message this.write(data); } async _data(msg, decoded) { return await transaction(this._knex, async trx => { const buffers = decoded.getBuffers(); const device = await this._device(buffers.DevAddr, trx); if (!device) { // we don't know this device return true; } if (!device.NwkSKey || !device.AppSKey) { // can't be abp because abp keys aren't nullable this.emit('not_joined', device, decoded); return true; } if (!lora_packet.verifyMIC(decoded, device.NwkSKey)) { this.emit('verify_mic_failed', device, decoded); return true; } const fcnt = decoded.getFCnt(); if (fcnt < device.FCntUp) { this.emit('data_replay', device, decoded); return true; } await this._patch_device(device, { FCntUp: fcnt + 1 }, trx) return this.push( { msg: msg, packet: decoded, nwk_addr: device.NwkAddr, dev_eui: device.DevEUI, dev_addr: buffers.DevAddr, payload: lora_packet.decrypt(decoded, device.AppSKey, device.NwkSKey), reply: { header: this._header(msg, 1000000), // RECEIVE_DELAY1 (1s) encoding: { MType: 'Unconfirmed Data Down', DevAddr: buffers.DevAddr, FCtrl: { ADR: false, ADRACKReq: false, ACK: decoded.getMType() === 'Confirmed Data Up', FPending: false }, FPort: 1 } } }); }); } _skey(app_key, type, app_nonce, dev_nonce) { return Link.skey(this._options.netid, app_key, type, app_nonce, dev_nonce); } async _device(dev_addr, trx) { const device = await this._dev_addr_to_otaa_device(dev_addr, trx); if (device) { return device; } return await this._ABPDevices .query(trx) .findById(dev_addr); } async _join_data(data) { return data.reply; } /** * Combines the unique address of a device with the network identifier * configured for this {@link #link|link} (`options.netid`). * * @param {Buffer} nwk_addr - Device address within the network. * @returns {Buffer} - 25 lsb: `nwk_addr`; 7 msb: the 7 lsb of `options.netid`. */ nwk_addr_to_dev_addr(nwk_addr) { const dev_addr = Buffer.from(nwk_addr); // 7 msb of DevAddr match 7 lsb of netid (NwkID) dev_addr[0] &= 0x1; dev_addr[0] |= (this._options.netid[2] & 0x7f) << 1; return dev_addr; } /** * Checks if the supplied device address is on the network configured * for this {@link #link|link} (`options.netid`). * * @param {Buffer} dev_addr - Combined device and network address. * @returns {Buffer|null} - If the device is on the configured network then the 25 lsb of `dev_addr` otherwise `null`. */ dev_addr_to_nwk_addr(dev_addr) { // Check if from OTAA device on this network // (7 msb of DevAddr match 7 lsb of netid (NwkID)) if ((dev_addr[0] >> 1) === (this._options.netid[2] & 0x7f)) { const nwk_addr = Buffer.from(dev_addr); nwk_addr[0] &= 0x01; return nwk_addr } return null; } async _dev_addr_to_otaa_device(dev_addr, trx) { const nwk_addr = this.dev_addr_to_nwk_addr(dev_addr); if (nwk_addr) { // OTAA device return await this._OTAADevices .query(trx) .findById(nwk_addr); } return null; } async _deveui_to_otaa_device(deveui) { return await this._OTAADevices .query() .findOne('DevEUI', deveui); } /** * If the supplied device address is on the network configured * for this {@link #link|link} (`options.netid`) then return its unique * global device ID (IEEE EUI64). * * @param {Buffer} dev_addr - Combined device and network address. * @returns {Buffer|null} - If the device is on the configured network then its IEEE EUI64 ID otherwise `null`. */ async dev_addr_to_deveui(dev_addr) { const device = await this._dev_addr_to_otaa_device(dev_addr) if (device) { return device.DevEUI; } return null; } } // For testing Link.skey = function (netid, app_key, type, app_nonce, dev_nonce) { const cipher = crypto.createCipheriv('aes-128-ecb', app_key, ''); cipher.setAutoPadding(false); const skey = []; const update = buf => { // The octet order for all multi-octet fields is little endian for (let i = buf.length - 1; i >= 0; i -= 1) { skey.push(cipher.update(buf.slice(i, i + 1))); } }; update(Buffer.from([type])); update(app_nonce); update(netid); update(dev_nonce); update(Buffer.alloc(7)); skey.push(cipher.final()); return Buffer.concat(skey); }; exports.Link = Link;