Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,16 @@ module.exports = {
* trace: show intermediate decoding results
*/
loglevel: e['TTN_OSEM_loglevel'] || 'info',

/**
* commaseparated list of auth keys that are allowed to request
* authenticated routes when sent in the 'authorization' header
* eg GET /v1.1/ttndevices/:boxId
*/
authTokens: e['TTN_OSEM_authtoken'] ? e['TTN_OSEM_authtoken'].split(',') : [],

ttn: {
appId: e['TTN_OSEM_ttn_app'],
key: e['TTN_OSEM_ttn_key'], // the key requires full rights (settings, devices, messages)
},
};
46 changes: 46 additions & 0 deletions lib/database.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use strict';

const { Box } = require('openSenseMapAPI').models;
const { BoxNotFoundError } = require('./errors');

/**
* look up a Box in the database for a given ttn configuration
* @param {string} app_id
* @param {string} dev_id
* @param {string} [port]
*/
const boxFromDevId = function boxFromDevId (app_id, dev_id, port) {
return Box.find({
'integrations.ttn.app_id': app_id,
'integrations.ttn.dev_id': dev_id
})
.then(boxes => {
if (!boxes.length) {
throw new BoxNotFoundError(`for dev_id '${dev_id}' and app_id '${app_id}'`);
}

// filter the boxes by their configured port.
// also include boxes with undefined port.
const box = boxes.filter(box => {
const p = box.integrations.ttn.port;

return (p === port || p === undefined);
})[0];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The app_id and dev_id are not enforced to be unique on the model schema. You should add the requirement to the main project

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, the app_id, dev_id, port tuple is assumed to be unique..


if (!box) {
throw new BoxNotFoundError(`for port ${port}`);
}

return box;
});
};

const boxFromBoxId = function boxFromBoxId (id) {
return Box.findBoxById(id, { populate: false })
.catch(err => Promise.reject(new BoxNotFoundError(err.message)));
};

module.exports = {
boxFromBoxId,
boxFromDevId,
};
5 changes: 3 additions & 2 deletions lib/decoding/debug.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

const { bytesToInt } = require('./helpers');
const { DecodingError } = require('../errors');

/**
* returns a bufferTransfomer for transformation of a buffer to measurements.
Expand All @@ -29,11 +30,11 @@ const createBufferTransformer = function createBufferTransformer (box) {
transformer = [];

if (!byteMask) {
throw new Error('profile \'debug\' requires a valid byteMask');
throw new DecodingError('box requires a valid byteMask', 'debug');
}

if (box.sensors.length < byteMask.length) {
throw new Error(`box requires at least ${byteMask.length} sensors`);
throw new DecodingError(`box requires at least ${byteMask.length} sensors`, 'debug');
}

for (let i = 0; i < byteMask.length; i++) {
Expand Down
36 changes: 19 additions & 17 deletions lib/decoding/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

const { transformAndValidateArray, json } = require('openSenseMapAPI').decoding;
const { createLogger } = require('../logging');
const { DecodingError } = require('../errors');

const profiles = {
/* eslint-disable global-require */
Expand Down Expand Up @@ -50,7 +51,7 @@ const bufferToMeasurements = function bufferToMeasurements (buffer, bufferTransf
}

if (maskLength !== buffer.length) {
throw new Error(`incorrect amount of bytes: got ${buffer.length}, should be ${maskLength}`);
throw new DecodingError(`incorrect amount of bytes: got ${buffer.length}, should be ${maskLength}`);
}

// feed each bufferTransformer element
Expand Down Expand Up @@ -88,18 +89,18 @@ const decodeBuffer = function decodeBuffer (buffer, box, timestamp) {
return Promise.resolve().then(function () {
// should never be thrown, as we find a box by it's ttn config
if (!buffer.length) {
throw new Error('payload may not be empty');
throw new DecodingError('payload may not be empty');
}

if (!box.integrations || !box.integrations.ttn) {
throw new Error('box has no TTN configuration');
throw new DecodingError('box has no TTN configuration');
}

// select bufferTransformer according to profile
const profile = profiles[box.integrations.ttn.profile];

if (!profile) {
throw new Error(`profile '${box.integrations.ttn.profile}' is not supported`);
throw new DecodingError(`profile '${box.integrations.ttn.profile}' is not supported`);
}

const bufferTransformer = profile.createBufferTransformer(box);
Expand Down Expand Up @@ -149,32 +150,33 @@ const decodeJSON = json.decodeMessage;
* decodes the request payload in `req.body` to validated measurements,
* selects the decoder from `req.box` and applies `req.time`
* @see module:decoding~decodeBuffer
* @param {Request} req
* @param {TTNUplink} payload A valid uplink payload object as received from TTN
* @param {Box} box The senseBox to match the decoding
* @param {string} [time] An ISODate as fallback for message timestamps
* @return {Promise} Once fulfilled returns a validated array of measurements
*/
const decodeRequest = function decodeRequest (req) {
const decodeRequest = function decodeRequest (payload, box, time) {
// extract time from payload: if available use time from
// gateway, else use TTN API time, else use local request time
let time = req.time.toISOString();
let timeSource = 'local';
if (req.body.metadata) {
time = req.body.metadata.time;
if (payload.metadata) {
time = payload.metadata.time;
timeSource = 'TTN api';
if (req.body.metadata.gateways && req.body.metadata.gateways[0].time) {
time = req.body.metadata.gateways[0].time;
if (payload.metadata.gateways && payload.metadata.gateways[0].time) {
time = payload.metadata.gateways[0].time;
timeSource = 'gateway';
}
}

log.trace(`using ${timeSource} time & ${req.box.integrations.ttn.profile} decoder`);
log.trace(`using ${timeSource} time & ${box.integrations.ttn.profile} decoder`);

if (req.box.integrations.ttn.profile === 'json') {
return req.body.payload_fields
? decodeJSON(req.body.payload_fields)
: Promise.reject('no payload for profile `json` provided');
if (box.integrations.ttn.profile === 'json') {
return payload.payload_fields
? decodeJSON(payload.payload_fields)
: Promise.reject(new DecodingError('no payload for profile `json` provided'));
}

return decodeBase64(req.body.payload_raw, req.box, time);
return decodeBase64(payload.payload_raw, box, time);
};

module.exports = {
Expand Down
9 changes: 5 additions & 4 deletions lib/decoding/lora-serialization.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

const loraSerialization = require('lora-serialization').decoder,
{ createLogger } = require('../logging'),
{ LoraError } = require('../errors'),
{ findSensorIds, applyValueFromMeasurement } = require('./helpers');

const log = createLogger('decoder/lora-serialization');
Expand Down Expand Up @@ -41,7 +42,7 @@ const createBufferTransformer = function createBufferTransformer (box) {
byteMask.constructor !== Array ||
!byteMask.every(opts => typeof opts === 'object')
) {
throw new Error('profile \'lora-serialization\' requires valid decodeOptions');
throw new LoraError('profile \'lora-serialization\' requires valid decodeOptions');
}

let expectedSensorCount = byteMask.length;
Expand All @@ -67,7 +68,7 @@ const createBufferTransformer = function createBufferTransformer (box) {
if (['unixtime', 'latLng'].includes(el.decoder)) {
expectedSensorCount--;
} else if (!Object.keys(match).length) {
throw new Error('invalid decodeOptions. requires at least one of [sensor_id, sensor_title, sensor_type]');
throw new LoraError('invalid decodeOptions. requires at least one of [sensor_id, sensor_title, sensor_type]');
} else {
sensorMatchings.push(match);
}
Expand All @@ -82,7 +83,7 @@ const createBufferTransformer = function createBufferTransformer (box) {
}, 'matched sensors');

if (Object.keys(sensorIds).length !== expectedSensorCount) {
throw new Error('box does not contain sensors mentioned in byteMask');
throw new LoraError('box does not contain sensors mentioned in byteMask');
}

// create the transformer elements for each measurement.
Expand All @@ -95,7 +96,7 @@ const createBufferTransformer = function createBufferTransformer (box) {
typeof transformer !== 'function' ||
byteMask[i].decoder === 'decode'
) {
throw new Error(`'${byteMask[i].decoder}' is not a supported transformer`);
throw new LoraError(`'${byteMask[i].decoder}' is not a supported transformer`);
}

const mask = {
Expand Down
3 changes: 2 additions & 1 deletion lib/decoding/sensebox_home.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

const { bytesToInt, findSensorIds } = require('./helpers');
const { DecodingError } = require('../errors');

// alternative titles recognized for the sensors
const sensorMatchings = {
Expand Down Expand Up @@ -39,7 +40,7 @@ const createBufferTransformer = function createBufferTransformer (box) {
const sensorMap = findSensorIds(box.sensors, sensorMatchings);

if (Object.keys(sensorMap).length !== Object.keys(sensorMatchings).length) {
throw new Error('box does not contain valid sensors for this profile');
throw new DecodingError('box does not contain valid sensors for this profile', 'sensebox/home');
}

const transformer = [
Expand Down
43 changes: 43 additions & 0 deletions lib/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use strict';

/**
* All errors handled by the app must inherit from TTNError
* and should be defined here for easier discovery.
*/
class TTNError extends Error { }

class BoxNotFoundError extends TTNError {
constructor (message) {
super(`No box found ${message}`);
this.code = 404;
}
}

class PayloadError extends TTNError {
constructor (message) {
super(`Invalid payload: ${message}`);
this.code = 422;
}
}

class DecodingError extends PayloadError {
constructor (message, decoder = 'generic') {
super(`${message} (${decoder} decoder)`);
this.component = decoder;
}
}

class LoraError extends DecodingError {
constructor (message) {
super(message, 'lora-serialization');
}
}


module.exports = {
TTNError,
BoxNotFoundError,
PayloadError,
DecodingError,
LoraError,
};
41 changes: 27 additions & 14 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ const express = require('express'),
server = express(),
{ connect, mongoose } = require('openSenseMapAPI').db,
cfg = require('../config'),
{ getApp, getMqtt } = require('./ttn'),
{ createLogger } = require('./logging'),
v11Router = require('./routes/v1.1');

const log = createLogger('server');
const httpLog = createLogger('http');

server.use(function reqLogger (req, res, next) {
req.time = new Date();
log.debug({ req }, `${req.method} ${req.url} from ${req.ip}`);
httpLog.debug({ req }, `${req.method} ${req.url} from ${req.ip}`);
next();
});

Expand All @@ -24,8 +25,14 @@ server.use('/v1.1', v11Router);

const msg = `404 Not Found. Available routes:

POST /v1.1 webhook for messages from the TTN HTTP Integration
payload format: https://www.thethingsnetwork.org/docs/applications/http/
POST /v1.1
webhook for messages from the TTN HTTP Integration
payload format: https://www.thethingsnetwork.org/docs/applications/http/

GET /1.1/ttndevice/:boxId
get the TTN device for a given box. must be called once to enable the feature
for a box. (alternative to the webhook feature)
requires auth.
`;

server.use(function notFoundHandler (req, res, next) {
Expand All @@ -38,14 +45,20 @@ server.use(function notFoundHandler (req, res, next) {
next();
});

// launch server once connected to DB
// launch server & connect to TTN once connected to DB
mongoose.set('debug', false);
connect().then(function onDBConnection () {
server.listen(cfg.port, (err) => {
if (!err) {
log.info(`server listening on port ${cfg.port}`);
} else {
log.error(err);
}
});
});
connect()
.then(function onDBConnection () {
server.listen(cfg.port, (err) => {
if (!err) {
httpLog.info(`HTTP API listening on port ${cfg.port}`);
} else {
httpLog.error(err);
}
});

// app doesnt have to be initialized at startup,
// but makes first request faster & catches errors earlier
return Promise.all([getApp(), getMqtt()]);
})
.catch(httpLog.fatal);
14 changes: 12 additions & 2 deletions lib/logging.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,26 @@ const logger = bunyan.createLogger({
level: cfg.loglevel,
serializers: {
req: bunyan.stdSerializers.req,
res: bunyan.stdSerializers.res,
err: bunyan.stdSerializers.err,
box (box) {
return box
? { id: box._id, sensors: box.sensors, ttn: box.integrations.ttn }
: {};
},
res (res) {
const { responseTime, result: { code } } = res.locals;

return { responseTime, code };
},
}
});

/**
* Creates a bunyan logger that inherits some global settings
* @private
* @param {string} component name for the logger, stored in the logfield `component`
* @param {*} options bunyan options to be passed to the logger
* @param {*} [options] bunyan options to be passed to the logger
* @return a bunyan logger
*/
const createLogger = function createLogger (component, options) {
return logger.child(Object.assign({ component }, options));
Expand Down
Loading