@tbowmo/node-red-small-timer
Version:
Small timer node for Node-RED with support for sunrise, sunset etc. timers
631 lines (572 loc) • 27.8 kB
HTML
<script type="text/html" data-help-name="smalltimer">
<p>Timing node with sunset, sunrise, dusk, dawn etc possibilities</p>
<h3>Output</h3>
<dl class="message-properties">
<dt>payload <span class="property-type">string | object | number</span></dt>
<dd>This is set to the value of the onMsg or offMsg properties in the configuration (depending on state)</dd>
<dt>topic <span class="property-type">string</span></dt>
<dd>Topic set in the configuration properties</dd>
<dt>state <span class="property-type">string</span></dt>
<dd>Will be one of : `tempOn`, `tempOff`, or `auto`</dd>
</dl>
<p>
In auto state the output payload will follow the schedule specified, emitting the specified on / off messages in the node
</p>
<p>
By default the output message will only be sent when state is changing. This can be changed, by ticking of the <i>Repeat</i> checkbox
</p>
<h3>Input</h3>
<p>
The input can be used as an override by simply sending a text or numeric message as payload. Valid (case-insensitive)
messages passed in via msg.payload include:
</p>
<dl class="message-properties">
<dt class="optional">payload <span class="property-type">string as specified</span></dt>
<dd>
<ul>
<li>1 / on / true</li>
<li>0 / off / false</li>
<li>toggle</li>
<li>auto / default</li>
<li>sync</li>
</ul>
</dd>
<dt class="optional">timeout <span class="property-type">number</span></dt>
<dd>Optional value that defines the timeout for the on / off event specified in payload. If undefined, the configured values for on/off timeout will be used</dd>
<dt class="optional">reset <span class="property-type">boolean</span></dt>
<dd>if truthy the node will reset to auto mode</dd>
</dl>
<p>
NOTE! if input contains non decodable properties / strings, the input will be ignored, and an error will be emitted to Node-Red
</p>
<p>
if the message payload is set to the string "auto" or "default" then the node will switch back to the current auto state,
if it was forced to be on/off
</p>
<p>
The first two override messages (1/0) timeout after 24 hours (1440 minutes, user-settable) OR at the next change of schedule.
You can set different timeout values for on and off temporary overrides
</p>
<h3>Details</h3>
<h4>Basic operation</h4>
<p><b>Smalltimer</b> is a Node-RED timing node that sends messages for start and end conditions at any time, day, or month.</p>
<p>
In the simplest case, Smalltimer requires no input data. All you need to do is set the time for when it should emit an on
or off message. However, Smalltimer can do much more.
</p>
<h4>Advanced operation:</h4>
<p>
For example, you can use it to automatically turn on the lights during evenings and use it with the timeout function to
automatically turn off lights again after, for instance, 5 minutes during the night.<br>/
To achieve this, you send a message to the input with 'on' in the payload. Then, the node will switch on for the desired time
(see the 'on timeout' property in the configuration).
</p>
<h3>Configuration</h3>
<h4>On and off times</h4>
<p>specifies the times where the node should turn on or off</p>
<h4>Schedule rules</h4>
<p>This is used to specify the schedule of when the timer should or should not operate. The set of "rules" is evaluated one by one, and the outcome of the last decides if its on or off.</p>
<h4>Topic</h4>
<p>Topic that should be sent to</p>
<h4>On and off messages</h4>
<p>This is the messages that is sent as payload when on or off states happen. Can be configured as object, number or string values</p>
<h4>Options:</h4>
<p>
<code>Repeat</code> will continuously emit output at intervals when checked. If unchecked output is only emitted when state change is detected<br/>
<code>Emit on startup</code> Will emit a status message at startup, or redeploy, after 2 seconds (allowing node-red to warm up)<br/>
<code>Wrap midnight</code> this allows the node to wrap over midnight, eg. if you have on at 23:00 and off at 01:00, if this option is not set
then the node will assume that it should not turn on the output<br/>
<code>Debug enable</code> adds an extra output on the node, which includes debug information from suncalc library
</p>
<h3>References</h3>
<ul>
<li><a href="https://github.com/tbowmo/node-red-small-timer">Github</a> - The github repository</li>
<li><a href="https://github.com/mourner/suncalc">Suncalc</a></li>
</ul>
</script>
<script type="text/html" data-template-name="smalltimer">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
<div class="form-row">
<label for="node-input-position"><i class="fa fa-map-marker"></i> Location</label>
<input type="text" id="node-input-position" placeholder="Location">
</div>
<div class="form-row" style="margin-bottom: 0;">
<label></label>
<div style="display:inline-block;">
<div class="small-timer-timers small-timer-timers-header">
<div class="small-timer-time-column">Time of day</div>
<div>Offset</div>
<div>Timeout</div>
<div>Minimum</div>
</div>
</div>
</div>
<div class="form-row">
<label for="node-input-startTime"><i class="fa fa-clock-o"></i> On time</label>
<div style="display:inline-block;">
<div class="small-timer-timers">
<input type="hidden" id="node-input-startTime" class="hidden">
<select id="node-input-startTime-select"></select>
<input type="text" id="node-input-startOffset" placeholder="0">
<input type="text" id="node-input-onTimeout" placeholder="1440">
<input type="text" id="node-input-minimumOnTime" placeholder="0">
</div>
</div>
</div>
<div class="form-row">
<label for="node-input-endTime"><i class="fa fa-clock-o"></i> Off time</label>
<div style="display:inline-block;">
<div class="small-timer-timers">
<input type="hidden" id="node-input-endTime" class="hidden">
<select id="node-input-endTime-select"></select>
<input type="text" id="node-input-endOffset" placeholder="0">
<input type="text" id="node-input-offTimeout" placeholder="1440">
<div style="width: 59px; flex-shrink: 0;"></div>
</div>
</div>
</div>
<div class="form-row node-input-rules-container-row">
<label style="width: 400px;">Schedule rules</label>
<ol id="node-input-rules-container"></ol>
</div>
<hr />
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tasks"></i> Topic</label>
<input type="text" id="node-input-topic" placeholder="Topic">
</div>
<div class="form-row">
<label for="node-input-onMsg"><i class="fa fa-sign-out"></i> On message</label>
<input type="text" id="node-input-onMsg">
<input type="hidden" id="node-input-onMsgType">
</div>
<div class="form-row">
<label for="node-input-offMsg"><i class="fa fa-sign-out"></i> Off message</label>
<input type="text" id="node-input-offMsg">
<input type="hidden" id="node-input-offMsgType">
</div>
<hr />
<div class="form-row">
<label style="vertical-align: top;"><i class="fa fa-cog"></i> Options</label>
<div style="display:inline-block; width: 70%;">
<div class="small-timer-options">
<label><input type="checkbox" id="node-input-wrapMidnight" > Wrap midnight</label>
<label><input type="checkbox" id="node-input-injectOnStartup"> Emit on startup or redeploy</label>
<label><input type="checkbox" id="node-input-debugEnable" > Debug enable</label>
<label><input type="checkbox" id="node-input-sendEmptyPayload" > Send empty payload</label>
<div style="grid-column: 1/3;">
<label style="width: min-content !important;">
<input type="checkbox" id="node-input-repeat"> Repeat
</label><label id="row-repeatInterval" for="node-input-repeatInterval">, with interval (in seconds)
<input type="text" id="node-input-repeatInterval" placeholder="0" style="width: 55px !important;"/>
</label>
</div>
</div>
</div>
</div>
<input id="node-input-outputs" type="hidden" value="1">
</script>
<style>
.small-timer-timers {
display: flex;
flex-direction: row;
justify-content: space-around;
width: 327px;
gap: 10px;
}
.small-timer-timers-header {
align-items: flex-end;
}
.small-timer-timers-header div:not(:first-of-type) {
width: 100%;
}
.small-timer-time-column {
width: 120px;
flex-shrink: 0;
}
.small-timer-timers input {
width: 100% ;
}
.small-timer-timers select {
width: 120px ;
}
.small-timer-options {
display: grid;
grid-template-columns: auto auto;
}
.small-timer-options label {
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
vertical-align: baseline;
width: 120px ;
white-space: nowrap;
}
.small-timer-options input {
width: auto ;
vertical-align: baseline ;
}
.form-tips {
margin-bottom: 8px;
}
</style>
<script type="text/javascript">
(function () {
const monthNames = [
{ value: 1, label: "January" },
{ value: 2, label: "February" },
{ value: 3, label: "March" },
{ value: 4, label: "April" },
{ value: 5, label: "May" },
{ value: 6, label: "June" },
{ value: 7, label: "July" },
{ value: 8, label: "August" },
{ value: 9, label: "September" },
{ value: 10, label: "October" },
{ value: 11, label: "November" },
{ value: 12, label: "December" }
];
const weekNumbers = Array
.apply(null, { length: 52})
.map(Number.call, Number)
.map((item) => ({ label: `Week ${item+1}`, value: item + 101 }))
const oddEvenWeeks = [
{ value: 200, label: 'Even weeks' },
{ value: 201, label: 'Odd weeks' },
]
const weekDays = [
{ value: 101, label: 'Monday' },
{ value: 102, label: 'Tuesday' },
{ value: 103, label: 'Wednesday' },
{ value: 104, label: 'Thursday' },
{ value: 105, label: 'Friday' },
{ value: 106, label: 'Saturday' },
{ value: 100, label: 'Sunday' },
]
let dayNumbers = Array
.apply(null, { length: 31 })
.map(Number.call, Number)
.map((item) => ({ label: item + 1, value: item + 1 }));
const ruleType = [
{ value: 'include', label: '==' },
{ value: 'exclude', label: '!=' }
]
function exportRule(monthDay) {
var type = monthDay?.find(".typeSelect").val();
var month = monthDay?.find(".monthSelect").val();
var day = monthDay?.find(".daySelect").val();
return { type, month, day };
}
function offsetEnable(value, field) {
if(Number(value) < 2000 || Number(value) >= 10000) {
$(field).prop('disabled', true);
$(field).val(0)
} else {
$(field).prop('disabled', false);
}
}
function addGroup(groupName, select, options) {
const group = $(`<optGroup label="${groupName}" />`).appendTo(select);
options.forEach((r) => {
group.append($("<option></option>").val(r.value).text(r.label));
})
}
function rulesContainer() {
$("#node-input-rules-container").css('min-height', '150px').css('min-width', '450px').editableList({
header: $("<div style='padding-left: 34px; flex-grow: 1'>").append($.parseHTML(`
<div style='width:15%; display: inline-grid'>Type</div>
<div style='width:40%; display: inline-grid'>Month/week</div>
<div style='width:40%; display: inline-grid'>Day of month/week</div>
`)),
scrollOnAdd: true,
sortable: true,
removable: true,
addButton: 'Add rule',
addItem: function (container, i, opt) {
if (!opt.hasOwnProperty('r')) {
opt.r = {};
if (i > 0) {
var lastRule = $("#node-input-rules-container").editableList('getItemAt', i - 1);
var exported = exportRule(lastRule.element);
opt.r.month = exported.month;
opt.r.day = exported.day;
opt.r.type = exported.type;
// We could copy the value over as well and preselect it (see the 'activeElement' code below)
// But not sure that feels right. Is copying over the last value 'expected' behaviour?
// It would make sense for an explicit 'copy' action, but not sure where the copy button would
// go for each rule without being wasted space for most users.
// opt.r.v = exportedRule.v;
}
}
opt.element = container;
var rule = opt.r;
if (!rule.hasOwnProperty('month')) {
rule.month = 0;
}
if (!rule.hasOwnProperty('type')) {
rule.type = 'include';
}
if (!rule.hasOwnProperty('day')) {
rule.day = 0;
}
if (!opt.hasOwnProperty('i')) {
opt._i = Math.floor((0x99999 - 0x10000) * Math.random()).toString();
}
container.css({
overflow: 'hidden',
whiteSpace: 'nowrap',
display: "flex",
"align-items": "center"
});
const row = $('<div></div>', { style: "flex-grow:1" }).appendTo(container);
const typeSelect = $('<select/>', { style: "width:15%; margin-left:5px" }).addClass('typeSelect').appendTo(row);
const monthSelect = $('<select/>', { style: "width:40%; margin-left:5px" }).addClass('monthSelect').appendTo(row);
const daySelect = $('<select/>', { style: "width:40%; margin-left:5px" }).addClass('daySelect').appendTo(row);
monthSelect.on('change', function () {
let currentDay = Number(daySelect.val());
daySelect.empty()
daySelect.append($("<option></option>").val(0).text('Every day'))
if (this.value < 100) {
addGroup('Day of month', daySelect, dayNumbers)
} else if (currentDay < 100) {
currentDay = 0
}
addGroup('Day of week', daySelect, weekDays)
daySelect.val(currentDay)
})
ruleType.forEach((r) => {
typeSelect.append($("<option></option>").val(r.value).text(r.label));
})
monthSelect.append($("<option></option>").val(0).text('Every month/week'))
addGroup('Month', monthSelect, monthNames)
addGroup('Odd/Even weeks', monthSelect, oddEvenWeeks)
addGroup('Weeks', monthSelect, weekNumbers)
daySelect.append($("<option></option>").val(0).text('Every day'))
if (rule.month < 100) {
addGroup('Day of month', daySelect, dayNumbers)
}
addGroup('Day of week', daySelect, weekDays)
monthSelect.val(rule.month);
typeSelect.val(rule.type);
daySelect.val(rule.day);
},
removeItem: function (opt) {
var rules = $("#node-input-rule-container").editableList('items');
rules.each(function (i) {
$(this).find(".node-input-rule-index").html(i + 1);
});
},
});
}
function repeatIntervalVisible() {
if ($('#node-input-repeat').is(':checked')) {
$('#row-repeatInterval').show()
} else {
$('#row-repeatInterval').hide()
}
}
function validatePositiveNumber(n) {
var num = Number(n || 0)
return !isNaN(num) && num >= 0
}
RED.nodes.registerType('smalltimer', {
category: 'advanced-input',
color: "#88dd22",
inputLabels: "Optional overrides",
outputLabels: ["status message", "debug output"],
defaults: {
name: { value: 'Small timer', required: true },
position: { value: "", type: "position", required: false },
startTime: { value: 5001, required: true },
endTime: { value: 1425, required: true },
startOffset: { value: 0, required: true, validate: (v) => !isNaN(v || 0) },
endOffset: { value: 0, required: true, validate: (v) => !isNaN(v || 0) },
topic: { value: '' },
sendEmptyPayload: {value: true, required: true},
onMsg: { value: '1' },
onMsgType: { value: 'str' },
onTimeout: { value: 1440, required: true, validate: validatePositiveNumber },
minimumOnTime: { value: 0, required: true, validate: validatePositiveNumber},
offMsg: { value: '0' },
offMsgType: { value: 'str' },
offTimeout: { value: 1440, required: true, validate: validatePositiveNumber},
rules: { value: [{ type: 'include', month: 0, day: 0 }] },
repeat: { value: false, required: true },
repeatInterval: {value: 60, validate: validatePositiveNumber},
wrapMidnight: { value: true },
injectOnStartup: { value: false, required: true },
debugEnable: { value: false },
outputs: { value: 1 },
// legacy prop
timeout: { value: 1440 },
},
icon: "timer.png",
inputs: 1,
outputs: 1,
label: function () {
return this.name || this.prefix;
},
labelStyle: function () {
return this.name ? "node_label_italic" : "";
},
oneditprepare: function () {
var node = this
repeatIntervalVisible()
$('#node-input-repeatInterval').val(node.repeatInterval || 60)
node.onTimeout = node.onTimeout ?? node.timeout
$('#node-input-onTimeout').val(node.onTimeout)
$('#node-input-sendEmptyPayload').prop('checked', node.sendEmptyPayload ?? true)
node.offTimeout = node.offTimeout ?? node.timeout
$('#node-input-offTimeout').val(node.offTimeout)
$("#node-input-onMsg").typedInput({
typeField: "#node-input-onMsgType",
default: 'str',
types: ["str", "num", "bool", "json"],
})
$("#node-input-offMsg").typedInput({
typeField: "#node-input-offMsgType",
default: 'str',
types: ["str", "num", "bool", "json"],
})
if (this.position) {
$.getJSON('smalltimer/sunCalc/' + this.position)
.done((sunCalcTimes) => {
generateTimes('#node-input-startTime', sunCalcTimes, false)
generateTimes('#node-input-endTime', sunCalcTimes, true)
})
.fail(() => {
const sunCalcTimes = [
{"id":"5101","label":"Sunrise"},
{"id":"5102","label":"Sunrise end"},
{"id":"5103","label":"Golden hour end"},
{"id":"5104","label":"Solar noon"},
{"id":"5105","label":"Golden hour"},
{"id":"5106","label":"Sunset start"},
{"id":"5107","label":"Sunset"},
{"id":"5108","label":"Dusk"},
{"id":"5109","label":"Nautical dusk"},
{"id":"5110","label":"Night"},
{"id":"5111","label":"Nadir"},
{"id":"5112","label":"Night end"},
{"id":"5113","label":"Nautical dawn"},
{"id":"5114","label":"Dawn"},
{"id":"5115","label":"Moonrise"},
{"id":"5116","label":"Moonset"}
]
generateTimes('#node-input-startTime', sunCalcTimes, false)
generateTimes('#node-input-endTime', sunCalcTimes, true)
})
} else {
generateTimes('#node-input-startTime', undefined, false)
generateTimes('#node-input-endTime', undefined, true)
}
offsetEnable($('#node-input-startTime').val(), '#node-input-startOffset')
offsetEnable($('#node-input-endTime').val(), '#node-input-endOffset')
$('#node-input-startTime-select').on('change', function () {
offsetEnable(this.value, '#node-input-startOffset')
})
$('#node-input-endTime-select').on('change', function () {
offsetEnable(this.value, '#node-input-endOffset')
})
rulesContainer()
this.rules.forEach((rule, i) => {
$("#node-input-rules-container").editableList('addItem', { r: rule, i: i });
})
$('#node-input-debugEnable').click(function () {
$('#node-input-outputs').val(this.checked ? 2 : 1)
})
$('#node-input-repeat').click(function () {
repeatIntervalVisible()
})
},
oneditsave: function () {
var rules = $("#node-input-rules-container").editableList('sort').editableList('items');
var node = this;
node.rules = [];
rules.each(function (i) {
node.rules.push(exportRule($(this)));
});
$('#node-input-startTime').val($('#node-input-startTime-select').val());
$('#node-input-endTime').val($('#node-input-endTime-select').val());
}
})
const conversionTable = {
'5000': '5114',
'5001': '5108',
'5002': '5104',
'5003': '5101',
'5004': '5107',
'5005': '5110',
'5006': '5112',
'5007': '5115',
'5008': '5116',
}
// Convert legacy special timestamps to new ones
function convertTimes(selectId) {
const currentVal = $(selectId).val()
const newValue = conversionTable[currentVal]
if (newValue !== undefined) {
$(selectId).val(newValue)
}
}
function leadingZero(n) {
return `0${n}`.slice(-2)
}
function generateTimes(selectId, sunMoon, endTime = false) {
$(selectId).hide()
convertTimes(selectId)
const pad = (n) => `00${n.toFixed(0)}`.substr(-2)
let fixedTimes = ''
for (let time = 0; time < 1439; time += 15) {
const minutes = time % 60
const hour = Math.floor(time / 60)
fixedTimes += `<option value=${time}>${pad(hour)}:${pad(minutes)}</option>`
}
fixedTimes += '<option value="1439">23:59 - Day end</option>'
let html = `
<optgroup label="Fixed time">
${fixedTimes}
</optgroup>
`
if (sunMoon) {
let sunCalc = ''
for (const item of sunMoon) {
if (item.date) {
const time = new Date(item.date)
const hour = leadingZero(time.getHours())
const minute = leadingZero(time.getMinutes())
const tString = `${hour}:${minute}`
sunCalc += `<option value="${item.id}">${tString} - ${item.label}</option>`
} else {
sunCalc += `<option value="${item.id}">${item.label}</option>`
}
}
html += `
<optgroup label="Dynamic sun and moon times">
${sunCalc}
</optgroup>`
}
if (endTime) {
html = html + `
<optgroup label="fixed durations">
<option value="10001">1 minute</option>
<option value="10002">2 minutes</option>
<option value="10005">5 minutes</option>
<option value="10010">10 minutes</option>
<option value="10015">15 minutes</option>
<option value="10030">30 minutes</option>
<option value="10060">60 minutes</option>
<option value="10090">90 minutes</option>
<option value="10120">120 minutes</option>
</optgroup>
`
}
$(`${selectId}-select`).html(html);
$(`${selectId}-select`).val($(selectId).val());
}
})()
</script>