UNPKG

@tbowmo/node-red-small-timer

Version:

Small timer node for Node-RED with support for sunrise, sunset etc. timers

454 lines (385 loc) 14 kB
/*eslint complexity: ["error", 13]*/ import { Node, NodeMessage, NodeStatus, NodeStatusFill, } from 'node-red' import { util } from '@node-red/util' import { ISmallTimerProperties, Rule } from '../nodes/common' import { SmallTimerChangeMessage, ISmallTimerMessage, Trigger, State, } from './interfaces' import { TimeCalc } from './time-calculation' import { Timer } from './timer' type NodeFunctions = Node type Position = { latitude: number, longitude: number, } const pad = (n: number) => n < 10 ? `0${n.toFixed(0)}` : `${n.toFixed(0)}` const SecondsTick = 1000 export class SmallTimerRunner { private readonly startupTock: ReturnType<typeof setTimeout> | undefined = undefined // Timing variables private tickTimer: ReturnType<typeof setInterval> | undefined = undefined private tickTimerInterval: number = 0 private lastPublish: number = 0 private override: State = 'auto' private currentState = false private readonly topic: string private readonly onMsg: string private readonly offMsg: string private readonly onMsgType: string private readonly offMsgType: string private readonly rules: Rule[] private readonly repeat: boolean private readonly repeatInterval: number private readonly onTimeout: number private readonly offTimeout: number private readonly timeCalc: TimeCalc private readonly debugMode: boolean private readonly timer = new Timer() private readonly sendEmptyPayload: boolean // Default to 20 seconds between ticks (update of state / node) private readonly defaultTickTimer = SecondsTick * 20 constructor( position: Position | undefined, configuration: ISmallTimerProperties, private readonly node: NodeFunctions, ) { this.timeCalc = new TimeCalc( position ? Number(position.latitude) : undefined, position ? Number(position.longitude) : undefined, configuration.wrapMidnight, Number(configuration.startTime), Number(configuration.endTime), Number(configuration.startOffset), Number(configuration.endOffset), Number(configuration.minimumOnTime), ) this.topic = configuration.topic this.onMsg = configuration.onMsg this.offMsg = configuration.offMsg this.onMsgType = configuration.onMsgType this.offMsgType = configuration.offMsgType this.rules = configuration.rules this.repeat = configuration.repeat this.repeatInterval = this.repeat ? Number(configuration.repeatInterval || 60) : 60 // default tick timer is 3 times as frequent as repeat timer, but never below 1 second this.defaultTickTimer = this.repeatInterval * SecondsTick / 3 if (this.defaultTickTimer < SecondsTick) { this.defaultTickTimer = SecondsTick } this.debugMode = configuration.debugEnable this.onTimeout = Number(configuration.onTimeout) this.offTimeout = Number(configuration.offTimeout) this.sendEmptyPayload = configuration.sendEmptyPayload ?? true if (configuration.injectOnStartup) { this.startupTock = setTimeout(this.forceSend.bind(this), 2 * SecondsTick) } else { this.calcState() this.updateNodeStatus() } this.startTickTimer(this.defaultTickTimer) } /** * Calculates the current state of the output (combines override and auto state) * @returns boolean */ private getCurrentState(): boolean { return this.override === 'auto' ? this.currentState : (this.override === 'tempOn') } private generateDebug(): NodeMessage { return { ...this.timeCalc.debug(), override: this.override, weekNumber: this.getWeekNumber(), topic: 'debug', } as NodeMessage // we cheat a bit to escape type checking in typescript } private generateMsg(trigger: Trigger): SmallTimerChangeMessage { const status = this.getCurrentState() const payload = status ? util.evaluateNodeProperty(this.onMsg, this.onMsgType, this.node, {}) : util.evaluateNodeProperty(this.offMsg, this.offMsgType, this.node, {}) return { state: this.override, stamp: Date.now(), autoState: this.override === 'auto', duration: 0, temporaryManual: this.override !== 'auto', timeout: this.timer.timeLeft(), payload: payload, topic: this.topic, trigger, } } private publishState(trigger: Trigger): void { const status = this.generateMsg(trigger) const shouldSendStatus = this.sendEmptyPayload || status.payload !== '' if (this.debugMode) { this.node.send([ shouldSendStatus ? status : null, this.generateDebug(), ]) return } if (shouldSendStatus) { this.node.send(status) } } private getWeekNumber(): number { // Idea from https://weeknumber.com/how-to/javascript const currentDate = new Date() currentDate.setHours(0, 0, 0, 0) // Thursday in current week decides the year. currentDate.setDate(currentDate.getDate() + 3 - (currentDate.getDay() + 6) % 7) // January 4 is always in week 1. const week1 = new Date(currentDate.getFullYear(), 0, 4) // Adjust to Thursday in week 1 and count number of weeks from date to week1. return 1 + Math.round(((currentDate.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7) } private isDayOk(date = new Date()): boolean { const month = date.getMonth() + 1 const week = this.getWeekNumber() const dayOfMonth = date.getDate() const dayOfWeek = date.getDay() const oddOrEvenWeek = Math.floor(week / 2) === week / 2 ? 200 : 201 const validMonths = [0, month, week + 100, oddOrEvenWeek] const validDays = [0, dayOfMonth, dayOfWeek + 100] let isOk = false this.rules.forEach((item: Rule) => { const ruleMonth = Number(item.month) const ruleDay = Number(item.day) if (validMonths.includes(ruleMonth) && validDays.includes(ruleDay)) { isOk = item.type === 'include' } }) return isOk } /** * Calculates, and set, new state. If a change from previous state is detected, the method returns true, * otherwise it returns false */ private calcState(): boolean { const date = new Date() const newState = this.timeCalc.getOnState(date) // Check if the new state is different, because then we need to send a change event if ((this.isDayOk(date) || this.currentState) && (newState !== this.currentState)) { this.currentState = newState return true } return false } private shouldRepeatPublish(): boolean { const seconds = Date.now() / SecondsTick if (this.repeat && (seconds - this.lastPublish >= this.repeatInterval)) { this.lastPublish = seconds return true } return false } /** * Handle timer updates */ private timerEvent(): void { const change = this.calcState() const nextChange = this.updateNodeStatus() if (nextChange < 60) { this.startTickTimer(SecondsTick) } else { this.startTickTimer(this.defaultTickTimer) } if (change || this.shouldRepeatPublish()) { this.publishState('timer') } } private forceSend(trigger: Trigger = 'timer'): void { this.calcState() this.updateNodeStatus() this.publishState(trigger) } /** * Convert a time in minutes to hours / minutes string * @param time * @returns */ private getHumanTime(time: number): string { const hour = Math.floor(time / 60) const minutes = time % 60 // Seconds are fractions, so we need to "up-cycle" them const seconds = Math.floor((time - Math.floor(time)) * 60) const str: string[] = [] if (hour) { str.push(`${pad(hour)}hrs`) } if (minutes >= 1 || hour) { str.push(`${pad(minutes)}mins`) } else { str.push(`${pad(seconds)}secs`) } return str.join(' ') } /** * Updates the node status */ private updateNodeStatus() { let fill: NodeStatusFill = 'yellow' const text: string[] = [] const activeToday = this.timeCalc.operationToday() === 'normal' && (this.isDayOk() || this.currentState) if (!activeToday) { text.push('No action today') } switch (this.timeCalc.operationToday()) { case 'noMidnightWrap': text.push('off time is before on time') break case 'minimumOnTimeNotMet': text.push('minimum on time not met') } let nextTimeoutOrAuto = Number.MAX_SAFE_INTEGER if (activeToday || this.override !== 'auto') { // default off state fill = 'red' let state = 'OFF' let nextAutoChange = this.timeCalc.getTimeToNextStartEvent() if (this.getCurrentState()) { // Signal that we have turned ON fill = 'green' state = 'ON' nextAutoChange = this.timeCalc.getTimeToNextEndEvent() } const timeout = this.timer.timeLeft() nextTimeoutOrAuto = timeout && (timeout < nextAutoChange) ? timeout : nextAutoChange if (this.override !== 'auto') { text.length = 0 // Reset array text.push(`Temporary ${state} for ${this.getHumanTime(nextTimeoutOrAuto)}`) } else { text.push(`${state} for ${this.getHumanTime(nextTimeoutOrAuto)}`) } } const status: NodeStatus = { fill, shape: this.override !== 'auto' ? 'ring' : 'dot', text: text.join(' - '), } this.node.status(status) return nextTimeoutOrAuto } private doOverride(override: State, timeout?: number): void { this.override = override if (override === 'auto') { this.timer.stop() return } // requested temporary state is the same as what would be the current auto state // So let's just set it to auto if ((override === 'tempOn') === this.currentState) { this.override = 'auto' this.timer.stop() return } const timerTimeout = override === 'tempOn' ? (timeout ?? this.onTimeout) : (timeout ?? this.offTimeout) this.timer.start(timerTimeout, () => { this.override = 'auto' this.forceSend() }) } /** * Handle messages as they are received * @param incomingMsg */ // eslint-disable-next-line complexity public onMessage( incomingMsg: Readonly<ISmallTimerMessage>, ): void { if (incomingMsg.reset !== undefined) { this.doOverride('auto') this.forceSend('input') return } const payload = typeof incomingMsg.payload === 'string' ? incomingMsg.payload.toLocaleLowerCase() : incomingMsg.payload const timeout = incomingMsg.timeout !== undefined ? Number(incomingMsg.timeout) : undefined if (timeout !== undefined && isNaN(timeout)) { throw new Error(`Timeout value "${incomingMsg.timeout}" can not be converted to a number`) } switch (payload) { case 0: case '0': case 'off': case 'false': case false: this.doOverride('tempOff', timeout) break case 1: case '1': case 'on': case 'true': case true: this.doOverride('tempOn', timeout) break case 'toggle': this.doOverride( this.getCurrentState() ? 'tempOff' : 'tempOn', timeout, ) break case 'auto': case 'default': this.doOverride('auto') break case 'sync': break default: throw new Error(`Did not understand the command '${incomingMsg.payload}' supplied in payload`) } this.forceSend('input') } /** * Cleanup before closing down (ie. remove timers) */ /* istanbul ignore next */ public cleanup(): void { if (this.startupTock) { clearTimeout(this.startupTock) } this.stopTickTimer() } /* istanbul ignore next */ private stopTickTimer(): void { if (this.tickTimer) { clearInterval(this.tickTimer) this.tickTimer = undefined } } private startTickTimer(newInterval: number): void { if (this.tickTimer && (newInterval === this.tickTimerInterval)) { // No need in (re) starting the tick timer, if it running with desired interval already return } this.stopTickTimer() this.tickTimerInterval = newInterval this.tickTimer = setInterval(this.timerEvent.bind(this), newInterval) } }