diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..bb23489 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,33 @@ +module.exports = { + "ecmaFeatures": { + "modules": true, + "spread" : true, + "restParams" : true + }, + "env": { + "es6": true, + "node": true, + "mocha": true + }, + "parserOptions": { + "ecmaVersion": 2017 + ,"sourceType": "module" + }, + "extends": "eslint:recommended", + "rules": { + "indent": [ + "error", + 2 + ], + "no-console": 0, + "semi": ["error", "never"], + "linebreak-style": [ + "error", + "unix" + ], + "quotes": [ + "error", + "single" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e8ee01 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/node_modules/ +/archive/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..d2cf0b0 --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +tests/ +test/ +*.test.js +testing/ +examples/ +*.lock +.eslintrc.js +.travis.yml diff --git a/examples/queue.js b/examples/queue.js new file mode 100644 index 0000000..bda1d81 --- /dev/null +++ b/examples/queue.js @@ -0,0 +1,36 @@ +import Schedule from '../src/schedule.js' +import Queue from '../src/queue.js' + +const HOUR = 0 +const MINUTE = 0 +const DELTA = 6 +const DURATION = 2 +const DEV = true // if true delta and duration are seconds +const NZONES = 4 + +let zones = [] +for (let zone=0; zone < NZONES; zone++) { + let opts = {dev:DEV, simulanteous:(zone % 2), name:'zone-'+(zone+1),hour:HOUR,minute:MINUTE,delta:DELTA, duration:DURATION} + zones[zone] = new Schedule (opts) + // console.log(zones[zone].name,'>hr:min:delta:duration:simultaneous=', + // zones[zone].hour, + // zones[zone].minute, + // zones[zone].delta, + // zones[zone].duration, + // console.dir(val) + // zones[zone].simultanecous) + // zones[zone].on('update',val => { + // console.log('just updated schedule') + // console.dir(val) + // }) + zones[zone].update() +} + +const queue = new Queue ({ + name: 'irrigation', + // one: true, + schedules: zones, +}) + +queue.start() +setTimeout(() => {queue.kill()},10*1000) diff --git a/examples/schedule.js b/examples/schedule.js new file mode 100644 index 0000000..7051d03 --- /dev/null +++ b/examples/schedule.js @@ -0,0 +1,25 @@ +import Schedule from '../src/schedule.js' +// import Queue from 'src/queue.js' + +const zone1 = new Schedule ({ + name: 'zone1', + hour: 2, + minute: 0, + delta: 6, + duration: 1 +}) + +console.log('zone1>hr:min:delta:duration=', zone1.hour,zone1.minute,zone1.delta,zone1.duration) + +zone1.on('update',val => { + console.dir(val) +}) + +zone1.update() +zone1.minute=30 +zone1.hour=6 +zone1.delta=12 +setTimeout(()=>{ + zone1.update() + process.exit() +},6000) diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..72863e2 --- /dev/null +++ b/nodemon.json @@ -0,0 +1,4 @@ +{ + "ignoreRoot": [".git"], + "watch": ["node_modules/@uci/","src/","examples/","index.js"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2394313 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "scheduler", + "version": "0.0.1", + "description": "an irrigation style scheduler interfacing with Home Assistant", + "type": "module", + "scripts": { + "start": "node ./example/scheduler.js", + "dev": "UCI_ENV=dev nodemon -r esm ./", + "example:sch": "nodemon examples/schedule.js", + "example:que": "nodemon examples/queue.js", + "dev:debug": "UCI_ENV=dev UCI_LOG_LEVEL=debug nodemon -r esm ./" + }, + "author": "", + "license": "ISC", + "dependencies": { + "await-to-js": "^2.1.1", + "clone": "^2.1.2", + "esm": "^3.2.25", + "moment": "^2.26.0", + "moment-duration-format": "^2.3.2" + }, + "devDependencies": { + "nodemon": "^1.19.1" + } +} diff --git a/src/queue.js b/src/queue.js new file mode 100644 index 0000000..f21f36c --- /dev/null +++ b/src/queue.js @@ -0,0 +1,199 @@ +import clone from 'clone' +import { EventEmitter } from 'events' + +// Queue Multiple Schedulers +class Queue extends EventEmitter { + constructor(opts) { + super(opts) + this.name = opts.name || 'queue' + this.queue = [] + if (opts.schedules) opts.schedules.forEach(sch => this.add(sch)) + this.one = opts.one + this._active = {} // list of active schedules organized by timeout + this._toID = 0 // id of timeout + this._delayed = [] + this._activeCount = 0 + this._active = {} // object or lists of active schedules by timeout id + this.init() + } + + get countdown() { + return (this.queue[0]||{}).countdown + } + + get names(){ + return this.queue.map(sch => { + return {name: sch.name, countdown: sch.countdown, next: sch.nextDT, simultaneous:sch.simultaneous} + }) + } + + get nextName(){ + return (this.queue[0]||{}).name + } + + init() { + + this.on('active', active=>{ + if (!active && this._delayed.length) { + const sch=this._delayed.shift() + // console.log('a delayed action now proceeding====>', sch.name, sch.toid) + start.call(this,sch) + } + }) + } + + setTimeout () { + // clearTimeout(this._timeout) + const next = this.queue[0] || {} + this._toID++ + console.log('+++++++++++++++++++++ next timeout',this._toID, 'in', Math.round(next.countdownMS/1000),'++++++++++++++++++++++++++') + this._timeout = setTimeout(() => { + console.log('**********************timeout triggered', this._toID, '********************************') + let dur = 0 + // don't mutate original queue, make copy for active list + const queue = clone(this.queue) + this._active[this._toID]=[] + queue.forEach(sch =>{ // add first and any others set for same time + dur += sch.durationMS + // console.log('check if active', sch.name, sch.countdownMS, dur) + if (sch.countdownMS <= dur) this._active[this._toID].push(sch) + if (sch.one) this.removeSchedule(sch.id) + }) + // this._active[this._toID].next = 0 + this.trigger(this._active[this._toID].length,this._toID) + this.emit('active',this._activeCount) + if (!this.one) this.start() + },next.countdownMS) + } + + stop () { + console.log('---------------------stopping queue, active schedules will complete--------------------------') + console.log(this._timeout) + clearTimeout(this._timeout) + } + + kill () { + console.log('---------------------stopping queue, active schedules will be terminated as well--------------------') + clearTimeout(this._timeout) + // console.log(this._active) + for (let list in this._active) { + this._active[list].forEach(sch=>{ + clearTimeout(sch._stopTimeout) + sch.stopAction()} + ) + } + } + + start() { + this.update() + this.setTimeout() + } + + trigger (count,toid) { // trigger all schedules actions that have same timestamp as first in queue + if (count < 1 || count > this._active[toid].length) { + // console.log('done: returning') + return + } + // console.log(count,toid,this._active[toid]) + const idx = this._active[toid].length-count + const sch = this._active[toid][idx] + sch.toid = toid + // let next = this._active[toid].next + // console.log(idx,sch) + count-- + if (idx===0 && !this._activeCount) start.call(this,sch) + if (sch.simultaneous) start.call(this,sch) + else { + // console.log('adding schedule to delay',sch.name,sch.toid) + this._delayed.push(sch) + // console.log(this._delayed.length) + } + this.trigger(count,toid) + } + + get nextTS() { + return (this.queue[0]||{}).nextTS + } + + get nextDT(){ + return (this.queue[0]||{}).nextDT + } + + add (sch) { + if (getBaseClass(sch) === 'UCISchedule') { + this.queue.push(sch) + this.sort() + } + else { + console.log('ERROR: passed schedule was not a instance of UCISchedule') + console.log(sch.name) + } + } + + removeSchedule (id) { + this.queue = this.queue.filter(a => a.id !== id) + } + + getSchedule (id) { + return this.queue.find(a => a.id === id) + } + + updateSchedule (id) { + this.getSchedule(id).update() + } + + update () { + for(let sch of this.queue) { + // console.log('updating', sch.name) + sch.update() + } + this.sort() + } + + sort () { + this.queue.sort((a,b) => a.nextTS - b.nextTS) + } +} + +export default Queue +export {Queue} + +function start (sch, toid) { + toid = sch.toid || toid + console.log('-----timeoutid',toid, 'start action-----') + sch.startAction() + this._activeCount++ + this.emit('active',1) + sch._stopTimeout = setTimeout(() =>{ // stop action timeout + console.log('---timeoutid',toid, 'stop action----') + sch.stopAction() + this._activeCount-- + // done remove from active list + this._active[toid]=this._active[toid].filter(s=>s.id!==sch.id) + if (!this._active[toid].length) { + // none left on that list, remove it + console.log('===========actions for timeout', toid, 'are complete===========') + delete this._active[toid] + } + // console.log('schedule action complete', sch.name,toid, this._activeCount) + this.emit('active',this._activeCount) + },sch.durationMS) +} + +function getBaseClass(targetClass){ + + if(targetClass instanceof Object){ + let baseClass = targetClass + + while (baseClass){ + const newBaseClass = Object.getPrototypeOf(baseClass) + + if(newBaseClass && newBaseClass !== Object && newBaseClass.name){ + baseClass = newBaseClass + }else{ + break + } + } + return baseClass.constructor.name + } +} diff --git a/src/schedule.js b/src/schedule.js new file mode 100644 index 0000000..2d4166d --- /dev/null +++ b/src/schedule.js @@ -0,0 +1,112 @@ +// import to from 'await-to-js' +import { EventEmitter } from 'events' +import moment from 'moment' +import 'moment-duration-format' + +// Single Device Scheduler User instance for each unique action +// see Schedulers to create a group with "collision protection" + +class UCISchedule extends EventEmitter { + constructor(opts) { + super(opts) + this.name = opts.name || 'test_schedule' + this.id = opts.id || this.name + this.hour = opts.hour || 0 + this.dev = opts.dev || process.env.UCI_ENV==='dev' + this.minute = opts.minute || 0 + this.delta = opts.delta || 6 // time to next trigger in hours + this.duration = opts.duration || 10 // in minutes + // computed values + this.nextTS = 0 // the next trigger time in seconds + this.enabled = opts.enabled || true + // this.active = false // if schedule is currently active + // this.on('active',active=>this.active=active) + this.simultaneous = opts.simulanteous // if true delay this scheduled event until current scheduled event completes + // this.lastActiveTS + this._startAction = opts.startAction || defaultStartAction.bind(this) // single (or array) of function(s) + this._stopAction = opts.stopAction || defaultStopAction.bind(this) // single (or array) of function(s) + } + + // async readyWait () { + // return new Promise( resolve => { + // if (!this.active) { + // resolve() + // return} + // this.once('active',active=>{if (!active) resolve()}) + // }) + // } + + get countdown() { + return moment.duration(parseInt((this.nextTS - Math.floor(Date.now()/1000))),'seconds').format('hh:mm:ss') + } + + // get countdownActive() { + // if (this.active) return moment.duration(parseInt((this.duration*60 + this.nextTS - Math.floor(Date.now()/1000))),'seconds').format('hh:mm:ss') + // + // } + + get countdownMS() { + return (this.nextTS*1000 - Date.now()) + } + + get durationMS() { + return (this.duration*(this.dev ? 1 :60)*1000) + } + + get nextDT() { return moment(this.nextTS*1000).format('ddd, MMM Do h:mm A') } + + // get activeDuration() { + // return moment.duration((Math.floor(Date.now()/1000) - this.nextTS),'seconds').format('hh:mm:ss') + // } + + // get activeRemaining() { + // return moment.duration((this.nextTS - Math.floor(Date.now()/1000) + this.duration * 60),'seconds').format('hh:mm:ss') + // } + + update() { // all TS in seconds + let baseTS = this.hour*3600+this.minute*60 + let dt = new Date() + let intoDayTS = (dt.getSeconds() + (60 * dt.getMinutes()) + (60 * 60 * dt.getHours())) + let nowTS = Math.floor(Date.now()/1000) + this.nextTS = nowTS - intoDayTS + baseTS + if (!this.dev) { + while (nowTS > this.nextTS) { + // console.log(`now ${nowTS} is beyond next ${this.nextTS} adding delta ${this.delta} hours`) + this.nextTS += this.delta * 3600 + } + } else this.nextTS = nowTS + this.delta + // console.log(baseTS,intoDayTS,nowTS, nowTS-intoDayTS,this.nextTS) + this.emit('update',{id:this.id, ts:this.nextTS,dt:this.nextDT,countdown:this.countdown,simultaneous:this.simultaneous}) + } + + registerStartAction(func) { + if (!Array.isArray(func)) func = [func] + this.startAction = func + } + + registerStopAction(func) { + if (!Array.isArray(func)) func = [func] + this.stopAction = func + } + + async startAction (data) { + this._startAction(data) + } + + async stopAction (data) { + this._stopAction(data) + } + +} + +function defaultStartAction () { + console.log('start action for', this.name,moment(Date.now()).format('ddd, MMM Do h:mm:ss A')) +} + +function defaultStopAction () { + console.log('stop action for', this.name,moment(Date.now()).format('ddd, MMM Do h:mm:ss A')) + +} + +export default UCISchedule +export {UCISchedule as Schedule}