SergiuToporjinschi/node-red-contrib-heater-controller

View on GitHub
nodes/heater/heater.js

Summary

Maintainability
C
7 hrs
Test Coverage
var UINode = require('./uINode')
const weekDays = [
    'Sunday',
    'Monday',
    'Tuesday',
    'Wednesday',
    'Thursday',
    'Friday',
    'Saturday'
];
class Heater extends UINode {
    debugOffSet = 0;
    logs = [];
    constructor(RED, config) {
        super(RED, config)
        this.log('Heater Constructor')
        if (typeof (this.config) !== 'object' || !Object.hasOwnProperty.call(this.config, 'group') || typeof (this.config.group) !== 'string') {
            this.error('Missing configuration or group!!!');
            throw Error('Missing configuration or group');
        }
        this.config.calendar = JSON.parse(this.config.calendar);
        this.initCurrentState();
        this.initTopics();
    }


    initTopics() {
        this.addEvent('currentTemp', this.onTempChange); //this topic changes currentTemperature (WE MUST RECEIVE THIS MESSAGE)
        this.addEvent('userConfig', this.onUserConfig);//this topic can be used to change user settings, isLocked, userTargetValue
        this.addEvent('setCalendar', this.onSetCalendar); //this topic can be used to change current calendar
        this.addEvent('logs', this._onLogsRequest); //this topic can be used to request the logs
        this.addEvent('config', this._onConfigRequest); //this topic can be used to request current node configuration
        this.addEvent('status', this._onStatusRequest); //this topic can be used to request current status node

        //FOR DEBUG ONLY
        /* istanbul ignore next */
        this.addEvent('debug', (function (msg) {
            this.warn('Debug topic should be used only for debug purpose!!!');
            if (msg.action === 'setStatus') {
                Object.assign(this.status, msg.payload);
            } else if (msg.action === 'offset') {
                this.debugOffSet = msg.payload.calChange;
            } else if (msg.action === 'reset') {
                this.status.currentSchedule.temp = 7;
                this.status.targetValue = 8;
                this.status.userTargetValue = 10;
                this.status.isUserCustom = false;
                this.status.isLocked = false;
                this.sendStatus();
            }
        }).bind(this)); //this topic can be used to change current calendar
    }

    initCurrentState() {
        this.status = {
            'currentTemp': undefined, //Current room temperature
            'targetValue': undefined, //Target temperature for the room
            'isUserCustom': undefined, //Is targetValue a user custom value?
            'isLocked': undefined, //Is targetValue locked (locker is true)
            'userTargetValue': undefined, //Target temperature set by user
            'currentSchedule': undefined, //Current target temperature from scheduler for this moment
            'nextSchedule': undefined, //Next target temperature from scheduler for next change
            'currentHeaterStatus': undefined, //Is heater running?
            'time': new Date().toLocaleString()
        }
        this.context().set('status', this.status);
        this.context().set('logs', this.logs);
        this.status.currentSchedule = this.getScheduleOffSet();
        this.status.nextSchedule = this.getScheduleOffSet(1);
    }

    _onConfigRequest() {
        return { config: this.filterConfig(this.config) };
    }

    _onStatusRequest() {
        return { status: this.status };
    }

    _onLogsRequest() {
        return { logs: this.logs };
    }

    onTempChange(msg) {
        //console.log('currentTemp');
        if (typeof (msg.payload) !== 'number') {
            this.error('onTempChange->Invalid payload [' + JSON.stringify(msg) + ']');
            throw new Error('Invalid payload');
        }

        this.status.currentTemp = msg.payload;

        //update schedulers
        this.status.currentSchedule = this.getScheduleOffSet(this.debugOffSet);
        this.status.nextSchedule = this.getScheduleOffSet(1 + this.debugOffSet);

        //recalculate status
        var calculatedStatus = this.recalculate();
        return { heaterStatus: calculatedStatus, status: this.status };
    }

    /**
     *
     * @param {object} msg
     * @todo Meeds refactoring of attribute check!!!
     */
    //TODO fix this
    // eslint-disable-next-line complexity
    onUserConfig(msg) {
        if (typeof (msg.payload) !== 'object' ||
            !(Object.hasOwnProperty.call(msg.payload, 'isLocked') || Object.hasOwnProperty.call(msg.payload, 'userTargetValue') || Object.hasOwnProperty.call(msg.payload, 'isUserCustom')) ||
            !(['undefined', 'boolean'].includes(typeof (msg.payload.isLocked)) &&
                ['undefined', 'number'].includes(typeof (msg.payload.userTargetValue)) &&
                ['undefined', 'boolean'].includes(typeof (msg.payload.isUserCustom)))) {
            this.error('onUserConfig->Invalid payload [' + JSON.stringify(msg) + ']');
            throw new Error('Invalid payload');
        }

        if (msg.payload.isUserCustom === true) {
            this.status.isUserCustom = true;
            this.status.userTargetValue = this.status.targetValue;
            msg.payload.userTargetValue = typeof (msg.payload.userTargetValue) !== 'undefined' ? msg.payload.userTargetValue : this.status.userTargetValue;
        } else if (msg.payload.isUserCustom === false) {
            delete msg.payload.isLocked; //if you send me isUserCustom = false I will decide if isLocked
            delete msg.payload.userTargetValue; //if you send me isUserCustom = false I will decide target temp
            this.status.isUserCustom = false;
            this.status.isLocked = false;
            this.status.targetValue = this.status.currentSchedule.temp;
        }

        if (msg.payload.isLocked === true) {
            this.status.isLocked = true;
            this.status.isUserCustom = true;
            //if I don't have a userTargetValue then take the current calendar value
            this.status.userTargetValue = typeof (this.status.targetValue) !== 'undefined' ? this.status.targetValue : this.status.currentSchedule.temp;
            this.status.targetValue = this.status.userTargetValue;
        } else if (msg.payload.isLocked === false) {
            this.status.isLocked = false;
            this.status.userTargetValue = this.status.targetValue;
        }

        if (msg.payload.userTargetValue) {
            this.status.userTargetValue = msg.payload.userTargetValue;
            this.status.targetValue = this.status.userTargetValue;
            this.status.isUserCustom = true;
        }
        this.status.userTargetValue = this.status.targetValue;
        //recalculate status
        var calculatedStatus = this.recalculate();
        return { heaterStatus: calculatedStatus, status: this.status };
    }

    onSetCalendar(msg) {
        if (typeof (msg.payload) !== 'object') {
            this.error('onSetCalendar->Invalid payload [' + JSON.stringify(msg) + ']');
            throw new Error('Invalid payload');
        }
        try {
            this.config.calendar = require('./calendarValidation').check(msg.payload);
        } catch (error) {
            this.error('Invalid calendar', error.details);
            throw error;
        }
        return { status: this.status };
    }

    /**
     * Returns the code for a specific offset
     * @param {Number} offSet the number of days as offset, can be undefined == 0
     */
    getSearchedInterval(offSet) {
        var intervalList = [];
        for (var i in this.config.calendar) {
            var dayId = weekDays.indexOf(i);
            for (var j in this.config.calendar[i]) {
                intervalList.push(dayId + j.replace(':', ''));
            }
        }
        intervalList.sort();
        var nowCode = new Date().getDay() + ('0' + new Date().getHours()).slice(-2) + ('0' + new Date().getMinutes()).slice(-2);
        var isInInterval = intervalList.indexOf(nowCode) >= 0;
        var currentPoz = intervalList.indexOf(nowCode);
        if (!isInInterval) {
            intervalList.push(nowCode);
            intervalList.sort();
            currentPoz = intervalList.indexOf(nowCode);
            intervalList.splice(currentPoz, 1);
            offSet--;
        }
        currentPoz = currentPoz + offSet;

        if (currentPoz < 0) currentPoz = intervalList.length - Math.abs(currentPoz); //if end of list then start from begging
        if (currentPoz > intervalList.length - 1) currentPoz = currentPoz - (intervalList.length);
        return intervalList[currentPoz];
    }

    /**
     * Calculates temperature for a specific interval
     *  @param {Object} userOffset An object with temperature for a specific time interval
     * {
     * temp : temperature,
     * time: starting time interval hh:mm
     * day: the week day name;
     * }
     */
    getScheduleOffSet(userOffset) {
        var intervalCode = this.getSearchedInterval(userOffset || 0);
        var weekDay = intervalCode[0];
        var time = intervalCode.substr(1).substr(0, 2) + ':' + intervalCode.substr(1).substr(2);

        var retObj = {
            time: time
        }
        retObj.day = weekDays[weekDay];
        retObj.temp = this.config.calendar[retObj.day][time];
        return retObj;
    }

    calculateStatus(targetValue) {
        if (this.status.currentTemp >= (this.config.threshold + targetValue)) {
            this.status.currentHeaterStatus = 'off';
        } else if (this.status.currentTemp <= (targetValue - this.config.threshold)) {
            this.status.currentHeaterStatus = 'on';
        }

        this._writeLog();
        //if currentTemp = 5, target = 5, threshold != 0 then there is a possibility for having no choice but keep the initial status
        //if there is no initial status then set it to OFF very very very low probability
        return this.status.currentHeaterStatus || this.oldStatus.currentHeaterStatus || 'off';
    }

    _writeLog() {
        if (this.config.logLength <= 0) return;
        var lastValue = this.logs.length > 0 ? this.logs[this.logs.length - 1] : {};
        if (lastValue.currentHeaterStatus === this.status.currentHeaterStatus) return;
        if (this.logs.length === this.config.logLength) {
            this.logs.shift();
        }
        this.logs.push(JSON.parse(JSON.stringify(this.status)));
    }

    //TODO fix this
    // eslint-disable-next-line complexity
    recalculate() {
        if (typeof (this.status) === 'undefined' || typeof (this.status.currentTemp) !== 'number') {
            this.debug('Recalculate: no current temperature!!!');
            return;
        }
        if (typeof (this.status.currentSchedule) !== 'object') {
            this.debug('Recalculate: no schedule!!!');
            return;
        }

        //first currentTemp will not be in old status, meaning is first set of currentTemp
        var forced_ByScheduler = this.oldStatus.currentTemp === undefined;

        //Schedule has been changed but the targetTemperature is locked, only currentTemp can change the status
        //I know that I could merge those if's but I want to keep it very simple
        if (!forced_ByScheduler && this.status.isLocked) {
            this.status.targetValue = this.status.userTargetValue;
            return this.calculateStatus(this.status.targetValue);
        }

        //if is not locked; Can be changed by calendar?
        var scheduleChanged = this.status.currentSchedule.temp !== this.oldStatus.currentSchedule.temp ||
            this.status.currentSchedule.day !== this.oldStatus.currentSchedule.day ||
            this.status.currentSchedule.time !== this.oldStatus.currentSchedule.time;


        //Schedule has been changed
        if (forced_ByScheduler || scheduleChanged) {
            this.status.targetValue = this.status.currentSchedule.temp;
            var heaterNewStatus = this.calculateStatus(this.status.targetValue);
            if (heaterNewStatus !== this.oldStatus.currentHeaterStatus) {
                this.status.isUserCustom = false;
            }
            return heaterNewStatus;
        }

        //if no other chases are reached the it will be user custom value else scenario should not exits
        if (this.status.isUserCustom) {
            this.status.targetValue = this.status.userTargetValue;
            return this.calculateStatus(this.status.targetValue);
        }

        //simply temperature goes up and reaches the target
        this.calculateStatus(this.status.targetValue);

        //if nothing changed
        return this.status.currentHeaterStatus; //should return an unchanged status ;
    }
}

module.exports = Heater