Apollon77/shelly-iot

View on GitHub
index.js

Summary

Maintainability
C
1 day
Test Coverage
/* jshint -W097 */
/* jshint -W030 */
/* jshint strict:true */
/* jslint node: true */
/* jslint esversion: 6 */
'use strict';

const EventEmitter = require('events');
const RestClient = require('node-rest-client').Client;
const coap = require('coap');
const Agent = coap.Agent;

Agent.prototype._nextToken = function nextToken() { // Needed Override in coap.Agent because of Shelly bug
    return new Buffer(0);
};

class ShellyIot extends EventEmitter {
    // Constructor, call with options object, content tbd :)
    constructor(options) {
        super();

        this.options = options || {};
        this.logger = this.options.logger;

        const restOptions = {};
        if (options.user && options.password) {
            restOptions.user = options.user;
            restOptions.password = options.password;
        }
        this.restClient = new RestClient(restOptions);


        coap.registerOption('Uri-Path', (str) => Buffer.from(str, 'ascii'), (buf) => buf.toString());
        coap.registerOption(3332, (str) => Buffer.from(str, 'ascii'), (buf) => buf.toString());
        coap.registerOption(3412, (str) => Buffer.alloc(2).writeUInt16BE(parseInt(str, 10), 0), (buf) => buf.readUInt16BE(0));
        coap.registerOption(3420, (str) => Buffer.alloc(2).writeUInt16BE(parseInt(str, 10), 0), (buf) => buf.readUInt16BE(0));

        this.knownDevices = {};

        this.coapServer = null;
    }

    correctPayloadString(payload) {
        try {
            payload = payload.toString();
            let index = payload.lastIndexOf('}');
            payload = payload.substring(0, index + 1);
            return payload;
        } catch (error) {
            return '';
        }
    }

    correctPayload(payloadString) {
        let index = payloadString.lastIndexOf('}');
        if (index > 0) {
            try {
                payloadString = payloadString.substring(0, index + 1);
                let payloadTest = JSON.parse(payloadString);
            } catch (error) {
                this.logger && this.logger('CoAP payload Error try to coorec');
                correctPayload(payloadString.substring(0, index))
            }
        }
        return payloadString; // nicht zu korrigieren, wir geben Ursprung zurück
    }

    initDevice(options, rsinfo) {
        const deviceId = options['3332'];
        if (!deviceId) return null;

        if (!this.knownDevices[deviceId]) {
            this.knownDevices[deviceId] = {
                'validity': 0,
                'lastPayload': null,
                'offlineTimer': null,
                'online': false,
                'ip': '',
                'port': 0,
                'description': null
            };
        }
        if (options['3412']) {
            options['3412'] = parseInt(options['3412'], 10);
            const scaler = options['3412'] & 1;
            if (scaler === 0) {
                this.knownDevices[deviceId].validity = Math.floor(options['3412'] / 10);
            }
            else {
                this.knownDevices[deviceId].validity = options['3412'] * 4;
            }
        }

        if (rsinfo) {
            if (this.knownDevices[deviceId] && this.knownDevices[deviceId].ip && rsinfo.address && this.knownDevices[deviceId].ip !== rsinfo.address) {
                this.knownDevices[deviceId].oldIp = this.knownDevices[deviceId].ip;
            }
            this.knownDevices[deviceId].ip = rsinfo.address;
            this.knownDevices[deviceId].port = rsinfo.port;
        }
        return deviceId;
    }

    handleDeviceStatus(req, emit, callback) {
        if (typeof emit === 'function') {
            callback = emit;
            emit = false;
        }
        const options = {};
        req.options.forEach((opt) => {
            if (!options[opt.name]) {
                options[opt.name] = opt.value;
            }
            else {
                options[opt.name] += '/' + opt.value;
            }
        });
        const deviceId = this.initDevice(options, req.rsinfo);
        if (!deviceId) {
            this.logger && this.logger('CoAP data invalid: ' + JSON.stringify(options));
            return;
        }

        const lastOnlineStatus = this.knownDevices[deviceId].online;
        this.knownDevices[deviceId].online = true;

        let payload = req.payload.toString();
        payload = this.correctPayload(payload);
        let payloadstr;

        if (!payload.length) {
            this.logger && this.logger('CoAP payload empty: ' + JSON.stringify(options));
            return;
        }
        try {

            if (payload.indexOf('`') !== -1) {
                this.logger && this.logger(payload);
                payload = payload.substr(0, payload.lastIndexOf('`'));
                this.logger && this.logger('CoAP payload cutted: ' + payload.indexOf('`'));
            }

            payloadstr = payload;
            payload = JSON.parse(payload);
        }
        catch (err) {
            this.emit('error', err + ' (req) for JSON ' + payload);
            return;
        }

        if (this.knownDevices[deviceId].offlineTimer) {
            clearTimeout(this.knownDevices[deviceId].offlineTimer);
            this.knownDevices[deviceId].offlineTimer = null;
        }

        this.knownDevices[deviceId].offlineTimer = setTimeout(() => {
            this.knownDevices[deviceId].online = false;
            this.emit('device-connection-status', deviceId, false);
        }, this.knownDevices[deviceId].validity * 1000);

        if (this.knownDevices[deviceId].description && options['3420'] && this.knownDevices[deviceId].lastPayload === payloadstr && !this.knownDevices[deviceId].oldIp) {
            this.logger && this.logger('CoAP data ignored: ' + JSON.stringify(options) + ' / ' + payloadstr);
            if (!lastOnlineStatus) this.emit('device-connection-status', deviceId, true);
            return;
        }
        if (options['3420']) {
            this.knownDevices[deviceId].lastPayload = payloadstr;
        }

        this.logger && this.logger('CoAP status package received: ' + JSON.stringify(options) + ' / ' + JSON.stringify(payload));
        if (this.knownDevices[deviceId].oldIp) {
            emit = true;
            delete this.knownDevices[deviceId].oldIp;
        }
        if (emit) this.emit('update-device-status', deviceId, payload);
        if (!lastOnlineStatus) this.emit('device-connection-status', deviceId, true);
        callback && callback(deviceId, payload);
    }

    listen(callback) {
        this.coapServer = coap.createServer({
            multicastAddress: '224.0.1.187',
            multicastInterface: this.options.multicastInterface || null
        });

        this.coapServer.on('request', (req, res) => {
            //this.logger && this.logger(JSON.stringify(req));
            if (req.url && req.url === '/cit/s' && req.code && req.code === '0.30') {
                res.end();
                this.handleDeviceStatus(req, true);
            }
        });

        this.coapServer.on('error', (err) => {
            this.emit('error', err);
        });

        this.coapServer.on('timeout', (err) => {
            this.emit('error', err);
        });

        // the default CoAP port is 5683
        this.coapServer.listen(5683, () => {
            callback && callback();
        });
    }

    // Call this method to discover all devices in the network and get their descriptions
    discoverDevices(callback) {
        this.logger && this.logger('Send CoAP multicast request for discovery');
        try {
            const req = coap.request({
                host: '224.0.1.187',
                //port: 5683,
                method: 'GET',
                pathname: '/cit/s',
                multicast: true,
                multicastTimeout: 500
            });
            /*        req.on('response', (res) => {
                        this.logger && this.logger('multicast response');
                        res.pipe(process.stdout);
                        res.on('end', () => {
                            console.log(res.options);
                            console.log(res.payload);
                        });
                    });*/
            req.on('error', (error) => {
                // console.log(error);
                this.emit('error', error);
            });
            req.end();
            callback && callback();
        } catch (e) {
            this.emit('error', e);
        }
    }

    // call this at the end to end listening for updates
    stopListening(callback) {
        if (this.coapServer) {
            this.coapServer.close();
            this.coapServer = null;
            this.emit('disconnect');
        }
        callback && callback();
    }

    // call this with a device ID to get the description
    getDeviceDescription(deviceId, callback, retryCounter) {
        let ip;
        if (!retryCounter) retryCounter = 0;
        if (retryCounter > 2) {
            return callback && callback(new Error('timeout on response'));
        }
        if (deviceId.includes('#')) {
            if (!this.knownDevices[deviceId] || !this.knownDevices[deviceId].ip) {
                return callback && callback('device unknown');
            }
            ip = this.knownDevices[deviceId].ip;
        }
        else ip = deviceId;
        this.logger && this.logger('CoAP device description request for ' + deviceId + ' to ' + ip + '(' + retryCounter + ')');
        let retryTimeout = null;
        try {
            const req = coap.request({
                host: ip,
                //port: 5683,
                method: 'GET',
                pathname: '/cit/d'
            });

            retryTimeout = setTimeout(() => {
                this.getDeviceDescription(deviceId, callback, ++retryCounter);
                callback = null;
            }, 2000);
            req.on('response', (res) => {
                clearTimeout(retryTimeout);
                const options = {};
                res.options.forEach((opt) => {
                    if (!options[opt.name]) {
                        options[opt.name] = opt.value;
                    }
                    else {
                        options[opt.name] += '/' + opt.value;
                    }
                });
                this.logger && this.logger('CoAP response: ' + JSON.stringify(options));

                const deviceId = this.initDevice(options, res.rsinfo);
                if (!deviceId) return;

                let payload = res.payload.toString();
                if (!payload.length) {
                    this.logger && this.logger('CoAP payload empty: ' + JSON.stringify(options));
                    return;
                }
                try {

                    if (payload.indexOf('`') !== -1) {
                        this.logger && this.logger(payload);
                        payload = payload.substr(0, payload.lastIndexOf('`'));
                        this.logger && this.logger('CoAP payload cutted: ' + payload.indexOf('`'));
                    }

                    payload = JSON.parse(payload);
                }
                catch (err) {
                    this.emit('error', err + ' (res) for JSON ' + payload);
                    return;
                }
                this.logger && this.logger('Device description received: ' + JSON.stringify(options) + ' / ' + JSON.stringify(payload));
                if (req.rsinfo) {
                    this.knownDevices[deviceId].ip = req.rsinfo.address;
                    this.knownDevices[deviceId].port = req.rsinfo.port;
                }
                this.knownDevices[deviceId].description = payload;
                callback && callback(null, deviceId, payload, this.knownDevices[deviceId].ip);
                callback = null;
            });
            req.on('error', (error) => {
                // console.log(error);
                this.emit('error', error);
            });
            req.end();
        }
        catch (e) {
            if (retryTimeout) clearTimeout(retryTimeout);
            callback && callback(e);
            callback = null;
        }
    }

    // call this with a device ID to get the data for one device
    getDeviceStatus(deviceId, callback, retryCounter) {
        let ip;
        if (!retryCounter) retryCounter = 0;
        if (retryCounter > 2) {
            return callback && callback(new Error('timeout on response'));
        }
        if (deviceId.includes('#')) {
            if (!this.knownDevices[deviceId] || !this.knownDevices[deviceId].ip) {
                return callback && callback('device unknown');
            }
            ip = this.knownDevices[deviceId].ip;
        }
        else ip = deviceId;
        this.logger && this.logger('CoAP device status request for ' + deviceId + ' to ' + ip + '(' + retryCounter + ')');

        let retryTimeout = null;
        try {
            const req = coap.request({
                host: ip,
                method: 'GET',
                pathname: '/cit/s',
            });

            retryTimeout = setTimeout(() => {
                this.getDeviceStatus(deviceId, callback, ++retryCounter);
                callback = null;
            }, 2000);
            req.on('response', (res) => {
                clearTimeout(retryTimeout);
                this.handleDeviceStatus(res, (deviceId, payload) => {
                    return callback && callback(null, deviceId, payload, this.knownDevices[deviceId].ip);
                });
            });
            req.on('error', (error) => {
                // console.log(error);
                callback && callback(error);
            });
            req.end();
        }
        catch (e) {
            if (retryTimeout) clearTimeout(retryTimeout);
            callback && callback(e);
            callback = null;
        }
    }

    // call this with a list of device IDs to request device updates as events
    requestDeviceStatusUpdates(devices) {
        if (!Array.isArray(devices)) {
            return false;
        }
        devices.forEach((deviceId) => {
            this.getDeviceStatus(deviceId, (err, deviceId, data, _ip) => {
                if (err) return;
                this.emit('update-device-status', deviceId, data);
            });
        });
    }


    callDevice(deviceId, path, params, callback) {
        if (typeof params === 'function') {
            callback = params;
            params = undefined;
        }
        let ip;
        if (deviceId.includes('#')) {
            if (!this.knownDevices[deviceId] || !this.knownDevices[deviceId].ip) {
                return callback && callback('device unknown');
            }
            ip = this.knownDevices[deviceId].ip;
        }
        else ip = deviceId;
        this.doGet('http://' + ip + path, params, (data, response) => {
            if (data && response && response.statusCode === 200) {
                this.logger && this.logger('REST Response ' + JSON.stringify(data));
                return callback && callback(null, data);
            }
            callback && callback(data);
        });
    }


    doGet(url, parameters, callback) {
        if (typeof parameters === 'function') {
            callback = parameters;
            parameters = undefined;
        }
        const data = {
            'parameters': parameters,
            'requestConfig': {
                'timeout': 1000, //request timeout in milliseconds
                'noDelay': true, //Enable/disable the Nagle algorithm
                'keepAlive': false //Enable/disable keep-alive functionalityidle socket.
                //keepAliveDelay: 1000 //and optionally set the initial delay before the first keepalive probe is sent
            },
            'responseConfig': {
                'timeout': 1000 //response timeout
            }
        };
        this.logger && this.logger('Call REST GET ' + url + ' with ' + JSON.stringify(parameters));
        const req = this.restClient.get(url, data, callback);
        req.on('error', function (err) {
            if (err.code) {
                err = err.code;
            }
            else if (err.message) {
                err = err.message;
            }
            else {
                err = err.toString();
            }

            callback(new Error('Error while communicating with Shelly device: ' + err));
        });
    }
}

module.exports = ShellyIot;