├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples ├── persistent_schedule.json ├── temp_schedule.json ├── time-schedule-default.txt └── time-schedule-event.txt ├── icons └── calendar.png ├── images ├── time-scheduler-demo.gif ├── time-scheduler-em.jpg ├── time-scheduler-flow.jpg └── time-scheduler.jpg ├── locales ├── de │ ├── time-scheduler.html │ └── time-scheduler.json └── en-US │ ├── time-scheduler.html │ └── time-scheduler.json ├── package.json ├── time-scheduler.html └── time-scheduler.js /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ---------- 4 | ## [v1.17.2] - 2022-08-21 5 | 6 | ### Changed 7 | - use widget color instead of titlebar color 8 | 9 | ---------- 10 | ## [v1.17.1] - 2022-06-19 11 | 12 | ### Changed 13 | - indicate disabled timer when device is disabled 14 | 15 | ---------- 16 | ## [v1.17.0] - 2022-04-24 17 | 18 | ### Changed 19 | - allow mixed start/end types 20 | 21 | ---------- 22 | ## [v1.16.2] - 2021-11-16 23 | 24 | ### Changed 25 | - fixed solar end time 26 | 27 | ---------- 28 | ## [v1.16.1] - 2021-10-16 29 | 30 | ### Changed 31 | - fixed output label 32 | 33 | ---------- 34 | ## [v1.16.0] - 2021-09-24 35 | 36 | ### Changed 37 | - allow timers to exceed midnight 38 | 39 | ### Fixed 40 | - improve css 41 | 42 | ---------- 43 | ## [v1.15.2] - 2021-08-13 44 | 45 | ### Fixed 46 | - properly convert "0" event to number in event mode 47 | 48 | ---------- 49 | ## [v1.15.1] - 2021-08-09 50 | 51 | ### Changed 52 | - select all now works both ways 53 | 54 | ### Fixed 55 | - de translation of sun events 56 | 57 | ---------- 58 | ## [v1.15.0] - 2021-07-29 59 | 60 | ### New 61 | - option to select context store 62 | 63 | ### Fixed 64 | - disabled devices can now be set again 65 | 66 | ---------- 67 | ## [v1.14.0] - 2021-07-24 68 | 69 | ### New 70 | - option to send off payloads only at the defined endtime 71 | 72 | ---------- 73 | ## [v1.13.2] - 2021-07-11 74 | 75 | ### Fixed 76 | - respect group width 77 | 78 | ### Changed 79 | - height calculation 80 | 81 | ---------- 82 | ## [v1.13.1] - 2021-07-10 83 | 84 | ### Changed 85 | - overview filter value is now stored in context 86 | - visual improvements for selected overview filter 87 | 88 | ---------- 89 | ## [v1.13.0] - 2021-07-01 90 | 91 | ### new 92 | - added additional sun events for default mode 93 | 94 | ---------- 95 | ## [v1.12.0] - 2021-07-01 96 | 97 | ### new 98 | - added additional sun events for event mode 99 | 100 | ---------- 101 | ## [v1.11.0] - 2021-05-17 102 | 103 | ### new 104 | - added select all for day select 105 | 106 | ---------- 107 | ## [v1.10.2] - 2021-04-23 108 | 109 | ### new 110 | - added getStatus input 111 | 112 | ### fixed 113 | - fixup empty ui path 114 | 115 | ---------- 116 | ## [v1.10.1] - 2021-04-05 117 | 118 | ### fixed 119 | - fixup empty lat/lon 120 | 121 | ---------- 122 | ## [v1.10.0] - 2021-04-05 123 | 124 | ### new 125 | - added solarevents 126 | 127 | ---------- 128 | ## [v1.9.0] - 2021-03-28 129 | 130 | ### new 131 | - option to block device output unless value changes 132 | - devices can be disabled/enabled now 133 | 134 | ---------- 135 | ## [v1.8.0] - 2021-03-21 136 | 137 | ### new 138 | - added configurable options for events 139 | 140 | ---------- 141 | ## [v1.7.1] - 2021-03-20 142 | 143 | ### fixed 144 | - fixed msg.topic error 145 | 146 | ---------- 147 | ## [v1.7.0] - 2021-03-20 148 | 149 | ### new 150 | - data is now stored in node.context 151 | 152 | ---------- 153 | ## [v1.6.1] - 2021-02-06 154 | 155 | ### fixed 156 | - fixed no payload error 157 | 158 | ---------- 159 | ## [v1.6.0] - 2021-01-29 160 | 161 | ### New 162 | - timers can be disabled/enabled now 163 | 164 | ---------- 165 | ## [v1.5.3] - 2021-01-22 166 | 167 | ### Changed 168 | - allow endtime to be 00:00 (midnight) 169 | 170 | ---------- 171 | ## [v1.5.2] - 2021-01-18 172 | 173 | ### Changed 174 | - gui improvements 175 | - overview only shows devices with timers 176 | 177 | ---------- 178 | ## [v1.5.1] - 2021-01-13 179 | 180 | ### Fixed 181 | - filter for timers 182 | 183 | ---------- 184 | ## [v1.5.0] - 2021-01-12 185 | 186 | ### Fixed 187 | - overview time zone 188 | 189 | ## Changed 190 | - bump from 0.4.9 to 1.5.0 191 | to follow semantic versioning 192 | 193 | ---------- 194 | ## [v0.4.9] - 2021-01-10 195 | 196 | ### New 197 | - optional msg topic 198 | 199 | ---------- 200 | ## [v0.4.8] - 2020-12-28 201 | 202 | ### Changed 203 | - improved time input for firefox/safari 204 | - improved ajax request with loading overlay 205 | 206 | ---------- 207 | ## [v0.4.7] - 2020-12-18 208 | 209 | ### New 210 | - added a overview site that is shown if multiple devices are configured 211 | 212 | ### Changed 213 | - adjusted pointer 214 | - removed refresh prop for event mode 215 | 216 | ---------- 217 | ## [v0.4.6] - 2020-12-07 218 | 219 | ### New 220 | - you can now change the first day of the week 221 | 222 | ---------- 223 | ## [v0.4.5] - 2020-11-30 224 | 225 | ### Fixed 226 | - device migration 227 | 228 | ---------- 229 | ## [v0.4.4] - 2020-11-24 230 | 231 | ### Fixed 232 | - CSS improvements 233 | 234 | ---------- 235 | ## [v0.4.3] - 2020-11-23 236 | 237 | ### New 238 | - event mode now has the ability to send 239 | custom payloads 240 | 241 | ---------- 242 | ## [v0.4.2] - 2020-11-22 243 | 244 | ### Changed 245 | - improved timing (output messages) 246 | 247 | ---------- 248 | ## [v0.4.1] - 2020-11-21 249 | 250 | ### Fixed 251 | - properly detect ui url (again) 252 | 253 | ---------- 254 | ## [v0.4.0] - 2020-11-19 255 | 256 | ### New 257 | - added new "event mode"! you can now 258 | send single events at a specific time. 259 | 260 | ---------- 261 | ## [v0.3.3] - 2020-11-09 262 | 263 | ### Fixed 264 | - properly detect ui url 265 | 266 | ---------- 267 | ## [v0.3.1] - 2020-11-09 268 | ## [v0.3.2] - 2020-11-09 269 | 270 | ### Fixed 271 | - support/fix for timezones 272 | 273 | ---------- 274 | ## [v0.3.0] - 2020-11-05 275 | 276 | ### New 277 | - added multi device support! you can now 278 | control multiple devices with just one 279 | scheduler node. 280 | - replaced plain text with i18n values, 281 | added german language support 282 | 283 | ### Changed 284 | - UI now actively requests data from the 285 | server instead of just relying on the 286 | msg.payload 287 | 288 | ### Fixed 289 | - time zone handling 290 | 291 | ---------- 292 | ## [v0.2.4] - 2020-10-22 293 | 294 | ### New 295 | - works now without any payload provided 296 | 297 | ### Changed 298 | - small gui improvements 299 | 300 | ---------- 301 | ## [v0.2.3] - 2020-10-15 302 | 303 | ### Added 304 | - License 305 | 306 | ---------- 307 | ## [v0.2.2] - 2020-10-14 308 | 309 | ### New 310 | - update interval can now be changed (default 60s) 311 | 312 | ---------- 313 | ## [v0.2.1] - 2020-10-06 314 | 315 | ### Fixed 316 | - node closing 317 | 318 | ---------- 319 | ## [v0.2.0] - 2020-10-06 320 | 321 | ### Changed 322 | - had to remove to ability to create "point in time" schedules 323 | might develop an extra node for this. The node now always 324 | outputs 'true' or 'false' every 60 seconds. 325 | 326 | ---------- 327 | ## [v0.1.3] - 2020-09-25 328 | 329 | ### Changed 330 | - help and readme 331 | 332 | ---------- 333 | ## [v0.1.0] - 2020-09-01 334 | 335 | ### Changed 336 | - new concept, new gui 337 | 338 | ---------- 339 | ## [v0.0.1] - 2020-08-22 340 | 341 | Initial release -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mario Fellinger 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-red-contrib-ui-time-scheduler 2 | A node-red-ui time scheduler for the Node-RED Dashboard. 3 | ___ 4 | ### NOTE: - This project is based on Angular v1 and node-red-dashboard - as both projects are now no longer maintained, this project should be considered to be deprecated as well. 5 | ___ 6 | | ![](images/time-scheduler.jpg) | ![](images/time-scheduler-em.jpg) | 7 | | :----------------------------: | :-------------------------------: | 8 | | *Default Mode* | *Event Mode* | 9 | 10 | ## Install 11 | 12 | You can install this node directly from the "Manage Palette" menu in the Node-RED interface. 13 | Alternatively, run the following command in your Node-RED user directory - typically `~/.node-red` on Linux or `%HOMEPATH%\.nodered` on Windows 14 | 15 | npm install node-red-contrib-ui-time-scheduler 16 | 17 | ### Requirements ### 18 | node-red v0.19 or above 19 | node-red-dashboard v2.10.0 (v2.15.4 or above would be ideal) 20 | 21 | ## Usage 22 | 23 | Add a time-scheduler-node to your flow. Open the dashboard, you will see an empty scheduler. 24 | Click the plus sign at the top right corner of the node to create a new timer. 25 | 26 | ### Input 27 | 28 | You can inject timers via a msg property `payload` (see [restoring schedules after a reboot](#Restoring-schedules-after-a-reboot) section). If the injected msg has a property `disableDevice` or `enableDevice` the node will disable/enable the devices output. Disabling/enabling works both with device name and index. 29 | 30 | ### Output 31 | 32 | Whenever you add, edit or delete a timer a JSON string is sent to the nodes top output. This JSON string contains all timers and settings. 33 | 34 | Every other output (number of total outputs depends on how many devices you have added) emits true/false every 60 seconds. In Event Mode the event is only sent at the specified time. Adjusting the refresh rate, choosing if a msg.`topic` is sent and if messages are blocked unless the value has changed is possible within the node's options. 35 | 36 | ### Restoring schedules after a reboot 37 | 38 | You can use the JSON string from the nodes top output to directly inject timers after a (re)boot or (re)deploy: 39 | 40 | ![](images/time-scheduler-flow.jpg) 41 | 42 | If you changed the node-red contextStorage to localfilesystem, timers are automatically saved and restored after a reboot. 43 | 44 | ### Frontend & Demo 45 | 46 | ![](images/time-scheduler-demo.gif) | 47 | :--: | 48 | *Time Scheduler Demo (Default Mode)* | 49 | 50 | ## Examples 51 | 52 | You can find example flows and schedules within the examples folder. 53 | Easily import flows via the Node-RED flow editor: 54 | 55 | ☰ -> Import -> Examples -> node-red-contrib-ui-time-scheduler 56 | 57 | ## History 58 | 59 | Find the changelog [here](CHANGELOG.md). 60 | 61 | # Donate 62 | 63 | You can donate by clicking the following link if you want to support this free project: 64 | 65 | 66 | -------------------------------------------------------------------------------- /examples/persistent_schedule.json: -------------------------------------------------------------------------------- 1 | [{"id":"28c056dc.6e2fba","type":"comment","z":"d8cb3879.806298","name":"### This scenario is permanent and requires a file to read an write to ###","info":"","x":320,"y":40,"wires":[]},{"id":"a9184512.6d0338","type":"file","z":"d8cb3879.806298","name":"WriteFile","filename":"/home/debian/Documents/time-schedule0.txt","appendNewline":true,"createDir":true,"overwriteFile":"true","encoding":"none","x":720,"y":80,"wires":[[]]},{"id":"4d5aed99.b82234","type":"inject","z":"d8cb3879.806298","name":"FireAfterReboot","props":[{"p":"payload","v":"","vt":"date"},{"p":"topic","v":"","vt":"string"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":160,"y":100,"wires":[["efd15a17.372ff8"]]},{"id":"efd15a17.372ff8","type":"file in","z":"d8cb3879.806298","name":"ReadFile","filename":"/home/debian/Documents/time-schedule0.txt","format":"utf8","chunk":false,"sendError":false,"encoding":"none","x":340,"y":100,"wires":[["dec834a4.768678"]]},{"id":"63338f84.0d9f5","type":"ui_switch","z":"d8cb3879.806298","name":"","label":"MyDevice","tooltip":"","group":"bfe48a3e.1b0f4","order":1,"width":0,"height":0,"passthru":true,"decouple":"false","topic":"","style":"","onvalue":"true","onvalueType":"bool","onicon":"","oncolor":"","offvalue":"false","offvalueType":"bool","officon":"","offcolor":"","x":720,"y":120,"wires":[[]]},{"id":"dec834a4.768678","type":"ui_time_scheduler","z":"d8cb3879.806298","group":"bfe48a3e.1b0f4","name":"","startDay":"0","refresh":60,"devices":["Device 1"],"onlySendChange":false,"customPayload":false,"eventMode":false,"eventOptions":[],"sendTopic":false,"lat":"","lon":"","outputs":2,"order":1,"width":0,"height":0,"x":510,"y":100,"wires":[["a9184512.6d0338"],["63338f84.0d9f5"]]},{"id":"bfe48a3e.1b0f4","type":"ui_group","name":"Scheduler","tab":"2936b813.6cde68","order":1,"disp":true,"width":"6","collapse":false},{"id":"2936b813.6cde68","type":"ui_tab","name":"Scheduler Demo","icon":"dashboard","order":1,"disabled":false,"hidden":false}] -------------------------------------------------------------------------------- /examples/temp_schedule.json: -------------------------------------------------------------------------------- 1 | [{"id":"a597ea5d.e1a858","type":"ui_switch","z":"d8cb3879.806298","name":"","label":"MyDevice","tooltip":"","group":"cebcd257.c7ba5","order":1,"width":0,"height":0,"passthru":true,"decouple":"false","topic":"","style":"","onvalue":"true","onvalueType":"bool","onicon":"","oncolor":"","offvalue":"false","offvalueType":"bool","officon":"","offcolor":"","x":360,"y":280,"wires":[[]]},{"id":"119486f6.6d81f9","type":"comment","z":"d8cb3879.806298","name":"### This scenario is only active until node-red restarts ###","info":"","x":270,"y":200,"wires":[]},{"id":"9d6b6653.333688","type":"ui_time_scheduler","z":"d8cb3879.806298","group":"cebcd257.c7ba5","name":"","startDay":"0","refresh":60,"devices":["Device 1"],"onlySendChange":false,"customPayload":false,"eventMode":false,"eventOptions":[],"sendTopic":false,"lat":"","lon":"","outputs":2,"order":1,"width":0,"height":0,"x":140,"y":260,"wires":[[],["a597ea5d.e1a858"]]},{"id":"cebcd257.c7ba5","type":"ui_group","name":"TempDemo","tab":"2936b813.6cde68","order":4,"disp":true,"width":"6","collapse":false},{"id":"2936b813.6cde68","type":"ui_tab","name":"Scheduler Demo","icon":"dashboard","order":1,"disabled":false,"hidden":false}] -------------------------------------------------------------------------------- /examples/time-schedule-default.txt: -------------------------------------------------------------------------------- 1 | {"timers":[{"starttime":1617778800000,"days":[1,0,0,0,0,1,1],"output":"0","endtime":1617793200000},{"starttime":1617800400000,"days":[0,1,1,1,1,0,0],"output":"0","endtime":1617822000000}],"settings":{"disabledDevices":[]}} 2 | -------------------------------------------------------------------------------- /examples/time-schedule-event.txt: -------------------------------------------------------------------------------- 1 | {"timers":[{"starttime":1617775200000,"days":[0,1,1,1,1,0,0],"output":"0","event":true},{"starttime":1617789600000,"days":[0,1,1,1,1,0,0],"output":"0","event":false}],"settings":{"disabledDevices":[]}} 2 | -------------------------------------------------------------------------------- /icons/calendar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fellinga/node-red-contrib-ui-time-scheduler/cc12ad1d93cef7a4184afd67e977511ff37d3a89/icons/calendar.png -------------------------------------------------------------------------------- /images/time-scheduler-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fellinga/node-red-contrib-ui-time-scheduler/cc12ad1d93cef7a4184afd67e977511ff37d3a89/images/time-scheduler-demo.gif -------------------------------------------------------------------------------- /images/time-scheduler-em.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fellinga/node-red-contrib-ui-time-scheduler/cc12ad1d93cef7a4184afd67e977511ff37d3a89/images/time-scheduler-em.jpg -------------------------------------------------------------------------------- /images/time-scheduler-flow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fellinga/node-red-contrib-ui-time-scheduler/cc12ad1d93cef7a4184afd67e977511ff37d3a89/images/time-scheduler-flow.jpg -------------------------------------------------------------------------------- /images/time-scheduler.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fellinga/node-red-contrib-ui-time-scheduler/cc12ad1d93cef7a4184afd67e977511ff37d3a89/images/time-scheduler.jpg -------------------------------------------------------------------------------- /locales/de/time-scheduler.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /locales/de/time-scheduler.json: -------------------------------------------------------------------------------- 1 | { 2 | "time-scheduler": { 3 | "payloadReceived": "Payload erhalten.", 4 | "invalidPayload": "Ungültige Payload.", 5 | "contextCreated": "Kontext erstellt.", 6 | "enabled": "aktiviert", 7 | "disabled": "deaktiviert", 8 | "label": { 9 | "name": "Name", 10 | "size": "Größe", 11 | "group": "Gruppe", 12 | "startDay": "Erster Tag", 13 | "singleOff": "Genaues Aus", 14 | "block": "Blocke", 15 | "refresh": "Refresh", 16 | "customPayload": "User Events", 17 | "on": "Ein", 18 | "off": "Aus", 19 | "context": "Kontext", 20 | "eventMode": "Event Modus", 21 | "eventOptions": "Events", 22 | "latitude": "Breitengrad", 23 | "longitude": "Längengrad", 24 | "devices": "Geräte", 25 | "device": "Gerät" 26 | }, 27 | "days": [ 28 | "Sonntag", 29 | "Montag", 30 | "Dienstag", 31 | "Mittwoch", 32 | "Donnerstag", 33 | "Freitag", 34 | "Samstag" 35 | ], 36 | "customPayloadDesc": "Akzeptiere Frontend-Freitexteingaben für Events", 37 | "eventModeDesc": "Erstelle einzelne Events anstatt eines Zeitplanes", 38 | "singleOffDesc": "Sende \"aus\" Nachrichten nur zur ausgewählten Endzeit", 39 | "onlySendChangeDesc": "Blockiere Nachrichten bis sich der Wert ändert", 40 | "topicDesc": "Sende den Gerätenamen per msg.topic", 41 | "copy": "Kopie", 42 | "ui": { 43 | "start": "Start", 44 | "starttime": "Start­zeit", 45 | "end": "Ende", 46 | "endtime": "Endzeit", 47 | "event": "Ereignis", 48 | "duration": "Dauer", 49 | "overview": "Übersicht", 50 | "days": [ 51 | "SO", 52 | "MO", 53 | "DI", 54 | "MI", 55 | "DO", 56 | "FR", 57 | "SA" 58 | ], 59 | "daysActive": "Wähle aktive Tage", 60 | "active": "Aktive", 61 | "all": "Alle", 62 | "custom": "Benutzerdefiniert", 63 | "sunrise": "Sonnenaufgang", 64 | "sunriseEnd": "Ende Sonnenaufgang", 65 | "goldenHourEnd": "Ende Goldene Stunde", 66 | "solarNoon": "Sonnenmittag", 67 | "goldenHour": "Goldene Stunde", 68 | "sunsetStart": "Beginn Sonnenuntergang", 69 | "sunset": "Sonnenuntergang", 70 | "dusk": "Abenddämmerung", 71 | "nauticalDusk": "naut. Abenddämmerung", 72 | "night": "Nacht", 73 | "nadir": "Nadir", 74 | "nightEnd": "Ende Nacht", 75 | "nauticalDawn": "naut. Morgendämmerung", 76 | "dawn": "Morgendämmerung", 77 | "payloadWarning": "Fehlerhafte oder keine Daten erhalten.", 78 | "emptyOverview": "Noch nichts geplant. Klicke auf \"Übersicht\" und wähle eines der Geräte.", 79 | "noActiveOverview": "Es sind keine Zeitpläne aktiv. Klicke auf \"Übersicht\" und wähle eines der Geräte.", 80 | "nothingPlanned": "Noch nichts geplant. Klicke auf das Plus Symbol um einen neuen Zeitplan zu erstellen.", 81 | "alertTimespan": "Die Auswahl überschreitet Mitternacht.\nTrotzdem speichern?", 82 | "alertTimespanDay": "Zeitspanne darf 24 Stunden nicht überschreiten.", 83 | "selectAll": "Alle auswählen" 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /locales/en-US/time-scheduler.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /locales/en-US/time-scheduler.json: -------------------------------------------------------------------------------- 1 | { 2 | "time-scheduler": { 3 | "payloadReceived": "Payload received.", 4 | "invalidPayload": "Payload invalid.", 5 | "contextCreated": "Context created.", 6 | "enabled": "enabled", 7 | "disabled": "disabled", 8 | "label": { 9 | "name": "Name", 10 | "size": "Size", 11 | "group": "Group", 12 | "startDay": "First Day", 13 | "singleOff": "Single Off", 14 | "block": "Block", 15 | "refresh": "Refresh", 16 | "customPayload": "User Events", 17 | "on": "On", 18 | "off": "Off", 19 | "context": "Context", 20 | "eventMode": "Event Mode", 21 | "eventOptions": "Event Options", 22 | "latitude": "Latitude", 23 | "longitude": "Longitude", 24 | "devices": "Devices", 25 | "device": "Device" 26 | }, 27 | "days": [ 28 | "Sunday", 29 | "Monday", 30 | "Tuesday", 31 | "Wednesday", 32 | "Thursday", 33 | "Friday", 34 | "Saturday" 35 | ], 36 | "customPayloadDesc": "Accept free-form frontend input for events", 37 | "eventModeDesc": "Schedule single events rather than a time span", 38 | "singleOffDesc": "Output \"off\" payloads only at the defined end time", 39 | "onlySendChangeDesc": "Block device output unless value changes", 40 | "topicDesc": "Send the device name via msg.topic", 41 | "copy": "copy", 42 | "ui": { 43 | "start": "Start", 44 | "starttime": "Starttime", 45 | "end": "End", 46 | "endtime": "Endtime", 47 | "event": "Event", 48 | "duration": "Duration", 49 | "overview": "Overview", 50 | "days": [ 51 | "SU", 52 | "MO", 53 | "TU", 54 | "WE", 55 | "TH", 56 | "FR", 57 | "SA" 58 | ], 59 | "daysActive": "Select active days", 60 | "active": "Active", 61 | "all": "All", 62 | "custom": "custom", 63 | "sunrise": "sunrise", 64 | "sunriseEnd": "sunrise end", 65 | "goldenHourEnd": "golden hour end", 66 | "solarNoon": "solar noon", 67 | "goldenHour": "golden hour", 68 | "sunsetStart": "sunset start", 69 | "sunset": "sunset", 70 | "dusk": "dusk", 71 | "nauticalDusk": "nautical dusk", 72 | "night": "night", 73 | "nadir": "nadir", 74 | "nightEnd": "night end", 75 | "nauticalDawn": "nautical dawn", 76 | "dawn": "dawn", 77 | "payloadWarning": "Invalid or no payload provided.", 78 | "emptyOverview": "Nothing planned yet. Click \"Overview\" and select a device.", 79 | "noActiveOverview": "No schedules are active. Click \"Overview\" and select a device.", 80 | "nothingPlanned": "Nothing planned yet. Click the plus sign to add a new timer.", 81 | "alertTimespan": "Time frame exceeds midnight.\nDo you want to continue?", 82 | "alertTimespanDay": "Time frame must not exceed 24 hours.", 83 | "selectAll": "Select All" 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-red-contrib-ui-time-scheduler", 3 | "version": "1.17.2", 4 | "description": "A ui time scheduler for the Node-RED Dashboard", 5 | "dependencies": { 6 | "suncalc": "^1.9.0" 7 | }, 8 | "main": "time-scheduler.js", 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "author": "Mario Fellinger ", 13 | "license": "MIT", 14 | "node-red": { 15 | "version": ">=0.19.6", 16 | "nodes": { 17 | "time_scheduler": "time-scheduler.js" 18 | } 19 | }, 20 | "engines": { 21 | "node": ">=12" 22 | }, 23 | "keywords": [ 24 | "node-red", 25 | "time-scheduler", 26 | "schedules", 27 | "timer", 28 | "dashboard", 29 | "frontend", 30 | "iot" 31 | ], 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/fellinga/node-red-contrib-ui-time-scheduler.git" 35 | } 36 | } -------------------------------------------------------------------------------- /time-scheduler.html: -------------------------------------------------------------------------------- 1 | 24 | 25 | 102 | 103 | -------------------------------------------------------------------------------- /time-scheduler.js: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2020 Mario Fellinger 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | module.exports = function(RED) { 26 | 'use strict'; 27 | const sunCalc = require('suncalc'); 28 | 29 | function HTML(config) { 30 | const uniqueId = config.id.replace(".", ""); 31 | const divPrimary = "ui-ts-" + uniqueId; 32 | 33 | const styles = String.raw` 34 | 82 | `; 83 | 84 | const timerBody = String.raw` 85 |
86 |
87 | ${config.devices[0]} 88 | 89 | 90 | 91 | ${RED._("time-scheduler.ui.overview")} 92 | {{devices[$index]}} 93 | 94 | 95 | 96 | 97 | 98 | {{isDeviceEnabled(myDeviceSelect) ? "alarm_on" : "alarm_off"}} 99 | 100 | 101 | {{isEditMode ? "close" : "add"}} 102 | 103 | 104 | 105 | filter_alt 106 | 107 | 108 | 109 | ${RED._("time-scheduler.ui.active")} {{overviewFilter === "all" ? "" : "check"}} 110 | 111 | 112 | ${RED._("time-scheduler.ui.all")} {{overviewFilter === "all" ? "check" : ""}} 113 | 114 | 115 | 116 | 117 |
118 | 119 | 142 |
143 | 144 | 145 |
146 | # 147 | ${config.eventMode ? ` 148 | ${RED._("time-scheduler.ui.start")} 149 | ${RED._("time-scheduler.ui.event")} 150 | ` : ` 151 | ${RED._("time-scheduler.ui.start")} 152 | ${RED._("time-scheduler.ui.end")} 153 | ${RED._("time-scheduler.ui.duration")} 154 | `} 155 |
156 |
157 | 158 |
159 |
160 | {{$index+1}} 161 | ${config.eventMode ? ` 162 | {{millisToTime(timer.starttime)}} 163 | {{eventToEventLabel(timer.event)}} 164 | ` : ` 165 | {{millisToTime(timer.starttime)}} 166 | {{millisToTime(timer.endtime)}} 167 | {{minutesToReadable(diff(timer.starttime,timer.endtime))}} 168 | `} 169 |
170 |
171 | 172 | {{days[dayIndex]}} 173 | 174 | 175 | {{days[dayIndex]}} 176 | 177 |
178 |
179 | 180 |
181 | 182 |
183 | 303 |
304 | `; 305 | 306 | return String.raw`${styles}${timerBody}`; 307 | } 308 | 309 | function checkConfig(config, node) { 310 | if (!config) { 311 | node.error(RED._("ui_time_scheduler.error.no-config")); 312 | return false; 313 | } 314 | if (!config.hasOwnProperty("group")) { 315 | node.error(RED._("ui_time_scheduler.error.no-group")); 316 | return false; 317 | } 318 | return true; 319 | } 320 | 321 | function TimeSchedulerNode(config) { 322 | try { 323 | let ui = undefined; 324 | if (ui === undefined) { 325 | ui = RED.require("node-red-dashboard")(RED); 326 | } 327 | 328 | RED.nodes.createNode(this, config); 329 | const node = this; 330 | 331 | // START check props 332 | if (!config.hasOwnProperty("refresh")) config.refresh = 60; 333 | if (!config.hasOwnProperty("startDay")) config.startDay = 0; 334 | if (!config.hasOwnProperty("height") || config.height == 0) config.height = 1; 335 | if (!config.hasOwnProperty("name") || config.name === "") config.name = "Time-Scheduler"; 336 | if (!config.hasOwnProperty("devices") || config.devices.length === 0) config.devices = [config.name]; 337 | if (!config.hasOwnProperty("eventOptions")) config.eventOptions = [{ label: RED._("time-scheduler.label.on"), event: "true" }, { label: RED._("time-scheduler.label.off"), event: "false" }]; 338 | // END check props 339 | config.i18n = RED._("time-scheduler.ui", { returnObjects: true }); 340 | config.solarEventsEnabled = ((config.lat !== "" && isFinite(config.lat) && Math.abs(config.lat) <= 90) && (config.lon !== "" && isFinite(config.lon) && Math.abs(config.lon) <= 180)) ? true : false; 341 | 342 | if (checkConfig(config, node)) { 343 | const done = ui.addWidget({ 344 | node: node, 345 | format: HTML(config), 346 | templateScope: "local", 347 | group: config.group, 348 | width: config.width, 349 | height: Number(config.height) + 3, 350 | order: config.order, 351 | emitOnlyNewValues: false, 352 | forwardInputMessages: false, 353 | storeFrontEndInputAsState: true, 354 | persistantFrontEndValue: true, 355 | beforeEmit: function(msg, value) { 356 | if (msg.hasOwnProperty("disableDevice")) { 357 | if (addDisabledDevice(msg.disableDevice)) { 358 | node.status({ fill: "green", shape: "ring", text: msg.disableDevice + " " + RED._("time-scheduler.disabled") }); 359 | msg.payload = serializeData(); 360 | node.send(msg); 361 | } 362 | } else if (msg.hasOwnProperty("enableDevice")) { 363 | if (removeDisabledDevice(msg.enableDevice)) { 364 | node.status({ fill: "green", shape: "dot", text: msg.enableDevice + " " + RED._("time-scheduler.enabled") }); 365 | msg.payload = serializeData(); 366 | node.send(msg); 367 | } 368 | } else if (msg.hasOwnProperty("getStatus")) { 369 | msg.payload = serializeData(); 370 | node.send(msg); 371 | return msg; 372 | } else { 373 | try { 374 | const parsedInput = JSON.parse(value); 375 | 376 | const parsedTimers = parsedInput.timers; 377 | if (validateTimers(parsedTimers)) { 378 | node.status({ fill: "green", shape: "dot", text: "time-scheduler.payloadReceived" }); 379 | setTimers(parsedTimers.filter(timer => timer.output < config.devices.length)); 380 | } else { 381 | node.status({ fill: "yellow", shape: "dot", text: "time-scheduler.invalidPayload" }); 382 | } 383 | 384 | if (parsedInput.settings) setSettings(parsedInput.settings); 385 | } catch (e) { 386 | node.status({ fill: "red", shape: "dot", text: e.toString() }); 387 | } 388 | } 389 | 390 | return { msg: [msg] }; 391 | }, 392 | beforeSend: function(msg, orig) { 393 | node.status({}); 394 | if (orig && orig.msg[0]) { 395 | setTimers(orig.msg[0].payload.timers); 396 | setSettings(orig.msg[0].payload.settings); 397 | const sendMsg = JSON.parse(JSON.stringify(orig.msg)); 398 | sendMsg[0].payload = serializeData(); 399 | addOutputValues(sendMsg); 400 | return sendMsg; 401 | } 402 | }, 403 | initController: function($scope) { 404 | $scope.init = function(config) { 405 | $scope.nodeId = config.id; 406 | $scope.i18n = config.i18n; 407 | $scope.days = config.i18n.days; 408 | $scope.devices = config.devices; 409 | $scope.myDeviceSelect = $scope.devices.length > 1 ? "overview" : "0"; 410 | $scope.eventMode = config.eventMode; 411 | $scope.eventOptions = config.eventOptions; 412 | } 413 | 414 | $scope.$watch('msg', function() { 415 | $scope.getTimersFromServer(); 416 | }); 417 | 418 | $scope.toggleViews = function() { 419 | $scope.isEditMode ? $scope.showStandardView() : $scope.showAddView(); 420 | } 421 | 422 | $scope.showStandardView = function() { 423 | $scope.isEditMode = false; 424 | $scope.getElement("timersView").style.display = "block"; 425 | $scope.getElement("messageBoard").style.display = "none"; 426 | $scope.getElement("overview").style.display = "none"; 427 | $scope.getElement("addTimerView").style.display = "none"; 428 | 429 | if (!$scope.timers) { 430 | $scope.getElement("timersView").style.display = "none"; 431 | 432 | const msgBoard = $scope.getElement("messageBoard"); 433 | msgBoard.style.display = "block"; 434 | msgBoard.firstElementChild.innerHTML = $scope.i18n.payloadWarning; 435 | } else if ($scope.myDeviceSelect === "overview") { 436 | $scope.getElement("timersView").style.display = "none"; 437 | $scope.getElement("overview").style.display = "block"; 438 | } else if ($scope.timers.filter(timer => timer.output == $scope.myDeviceSelect).length === 0) { 439 | $scope.getElement("timersView").style.display = "none"; 440 | 441 | const msgBoard = $scope.getElement("messageBoard"); 442 | msgBoard.style.display = "block"; 443 | msgBoard.firstElementChild.innerHTML = $scope.i18n.nothingPlanned; 444 | } 445 | } 446 | 447 | $scope.showAddView = function(timerIndex) { 448 | $scope.isEditMode = true; 449 | $scope.showSunSettings = false; 450 | $scope.getElement("timersView").style.display = "none"; 451 | $scope.getElement("messageBoard").style.display = "none"; 452 | $scope.getElement("addTimerView").style.display = "block"; 453 | $scope.formtimer = { 454 | index: timerIndex, 455 | dayselect: [], 456 | starttype: "custom", 457 | endtype: "custom", 458 | }; 459 | 460 | if (timerIndex === undefined) { 461 | const today = new Date(); 462 | if (today.getHours() == "23" && today.getMinutes() >= "54") today.setMinutes(53); 463 | const start = new Date(today.getFullYear(), today.getMonth(), today.getDay(), today.getHours(), today.getMinutes() + 1, 0); 464 | $scope.getElement("timerStarttime").value = $scope.formatTime(start.getHours(), start.getMinutes()); 465 | if ($scope.eventMode) $scope.formtimer.timerEvent = $scope.eventOptions.length > 0 ? $scope.eventOptions[0].event : "true"; 466 | else { 467 | const end = new Date(today.getFullYear(), today.getMonth(), today.getDay(), today.getHours(), today.getMinutes() + 6, 0); 468 | $scope.getElement("timerEndtime").value = $scope.formatTime(end.getHours(), end.getMinutes()); 469 | } 470 | $scope.formtimer.dayselect.push(today.getDay()); 471 | $scope.formtimer.disabled = false; 472 | } else { 473 | const timer = $scope.timers[timerIndex]; 474 | if (timer.hasOwnProperty("startSolarEvent")) $scope.formtimer.starttype = timer.startSolarEvent; 475 | if (timer.hasOwnProperty("startSolarOffset")) $scope.formtimer.startOffset = timer.startSolarOffset; 476 | if (timer.hasOwnProperty("endSolarEvent")) $scope.formtimer.endtype = timer.endSolarEvent; 477 | if (timer.hasOwnProperty("startSolarOffset")) $scope.formtimer.endOffset = timer.endSolarOffset; 478 | $scope.updateSolarLabels(); 479 | const start = new Date(timer.starttime); 480 | $scope.getElement("timerStarttime").value = $scope.formatTime(start.getHours(), start.getMinutes()); 481 | if ($scope.eventMode) $scope.formtimer.timerEvent = timer.event; 482 | else { 483 | const end = new Date(timer.endtime); 484 | $scope.getElement("timerEndtime").value = $scope.formatTime(end.getHours(), end.getMinutes()); 485 | } 486 | for (let i = 0; i < timer.days.length; i++) { 487 | if (timer.days[$scope.localDayToUtc(timer, i)]) $scope.formtimer.dayselect.push(i); 488 | } 489 | $scope.formtimer.disabled = timer.hasOwnProperty("disabled"); 490 | } 491 | } 492 | 493 | $scope.addTimer = function() { 494 | const now = new Date(); 495 | const startInput = $scope.getElement("timerStarttime").value.split(":"); 496 | const starttime = new Date(now.getFullYear(), now.getMonth(), now.getDate(), startInput[0], startInput[1], 0, 0).getTime(); 497 | 498 | const timer = { 499 | starttime: starttime, 500 | days: [0, 0, 0, 0, 0, 0, 0], 501 | output: $scope.myDeviceSelect 502 | }; 503 | 504 | if ($scope.formtimer.starttype !== "custom") { 505 | timer.startSolarEvent = $scope.formtimer.starttype; 506 | timer.startSolarOffset = $scope.formtimer.startOffset; 507 | } 508 | 509 | if ($scope.eventMode) { 510 | timer.event = $scope.formtimer.timerEvent; 511 | if (timer.event === "true" || timer.event === true) { 512 | timer.event = true; 513 | } else if (timer.event === "false" || timer.event === false) { 514 | timer.event = false; 515 | } else if (!isNaN(timer.event) && (timer.event === "0" || (timer.event + "").charAt(0) != "0")) { 516 | timer.event = Number(timer.event); 517 | } 518 | } else { 519 | const endInput = $scope.getElement("timerEndtime").value.split(":"); 520 | let endtime = new Date(now.getFullYear(), now.getMonth(), now.getDate(), endInput[0], endInput[1], 0, 0).getTime(); 521 | 522 | if ($scope.formtimer.endtype !== "custom") { 523 | timer.endSolarEvent = $scope.formtimer.endtype; 524 | timer.endSolarOffset = $scope.formtimer.endOffset; 525 | } 526 | 527 | if ($scope.formtimer.starttype === "custom" && $scope.formtimer.endtype === "custom" && $scope.diff(starttime, endtime) < 1) { 528 | if (confirm($scope.i18n.alertTimespan)) endtime += 24 * 60 * 60 * 1000; 529 | else return; 530 | } else if ($scope.formtimer.starttype !== "custom" && $scope.formtimer.endtype !== "custom") { 531 | if (timer.startSolarEvent === timer.endSolarEvent && (timer.startSolarOffset || 0) >= (timer.endSolarOffset || 0)) { 532 | alert($scope.i18n.alertTimespanDay); 533 | return; 534 | } 535 | } 536 | 537 | timer.endtime = endtime; 538 | } 539 | 540 | $scope.formtimer.dayselect.forEach(day => { 541 | const utcDay = $scope.localDayToUtc(timer, Number(day)); 542 | timer.days[utcDay] = 1; 543 | }); 544 | 545 | if ($scope.formtimer.disabled) timer.disabled = "disabled"; 546 | 547 | const timerIndex = $scope.formtimer.index; 548 | if (timerIndex === undefined) { 549 | $scope.timers.push(timer); 550 | } else { 551 | $scope.timers.splice(timerIndex, 1, timer); 552 | } 553 | 554 | $scope.sendTimersToOutput(); 555 | } 556 | 557 | $scope.deleteTimer = function() { 558 | $scope.timers.splice($scope.formtimer.index, 1); 559 | $scope.sendTimersToOutput(); 560 | } 561 | 562 | $scope.sendTimersToOutput = function() { 563 | if (!$scope.msg) $scope.msg = [{ payload: "" }]; 564 | $scope.msg[0].payload = { 565 | timers: angular.copy($scope.timers), 566 | settings: { 567 | disabledDevices: angular.copy($scope.disabledDevices), 568 | overviewFilter: angular.copy($scope.overviewFilter) 569 | } 570 | }; 571 | $scope.send([$scope.msg[0]]); 572 | } 573 | 574 | $scope.daysChanged = function() { 575 | if ($scope.formtimer.dayselect.length === 8) { 576 | $scope.formtimer.dayselect = []; 577 | } else if ($scope.formtimer.dayselect.includes('all')) { 578 | $scope.formtimer.dayselect = [0, 1, 2, 3, 4, 5, 6]; 579 | }; 580 | } 581 | 582 | $scope.minutesToReadable = function(minutes) { 583 | return (Math.floor(minutes / 60) > 0 ? Math.floor(minutes / 60) + "h " : "") + (minutes % 60 > 0 ? minutes % 60 + "m" : ""); 584 | } 585 | 586 | $scope.eventToEventLabel = function(event) { 587 | const option = $scope.eventOptions.find(o => { return o.event === event.toString() }); 588 | return option ? option.label : event; 589 | } 590 | 591 | $scope.millisToTime = function(millis) { 592 | const date = new Date(millis); 593 | return $scope.formatTime(date.getHours(), date.getMinutes()); 594 | } 595 | 596 | $scope.formatTime = function(hours, minutes) { 597 | return $scope.padZero(hours) + ":" + $scope.padZero(minutes); 598 | } 599 | 600 | $scope.updateSolarLabels = function() { 601 | const startOffset = $scope.formtimer.startOffset > 0 ? "+" + $scope.formtimer.startOffset : ($scope.formtimer.startOffset || 0); 602 | const startTypeLabel = startOffset === 0 ? $scope.i18n[$scope.formtimer.starttype] : $scope.i18n[$scope.formtimer.starttype].substr(0, 8); 603 | $scope.formtimer.solarStarttimeLabel = startTypeLabel + (startOffset != 0 ? " " + startOffset + "m" : ""); 604 | const endOffset = $scope.formtimer.endOffset > 0 ? "+" + $scope.formtimer.endOffset : ($scope.formtimer.endOffset || 0); 605 | const endTypeLabel = endOffset === 0 ? $scope.i18n[$scope.formtimer.endtype] : $scope.i18n[$scope.formtimer.endtype].substr(0, 8); 606 | $scope.formtimer.solarEndtimeLabel = endTypeLabel + (endOffset != 0 ? " " + endOffset + "m" : ""); 607 | } 608 | 609 | $scope.offsetValidation = function(type) { 610 | if (type === "start") { 611 | if ($scope.formtimer.startOffset > 300) $scope.formtimer.startOffset = 300; 612 | if ($scope.formtimer.startOffset < -300) $scope.formtimer.startOffset = -300; 613 | } else if (type === "end") { 614 | if ($scope.formtimer.endOffset > 300) $scope.formtimer.endOffset = 300; 615 | if ($scope.formtimer.endOffset < -300) $scope.formtimer.endOffset = -300; 616 | } 617 | $scope.updateSolarLabels(); 618 | } 619 | 620 | $scope.localDayToUtc = function(timer, localDay) { 621 | const start = new Date(timer.starttime); 622 | let shift = start.getUTCDay() - start.getDay(); 623 | if (shift < -1) shift = 1; 624 | if (shift > 1) shift = -1; 625 | let utcDay = shift + localDay; 626 | if (utcDay < 0) utcDay = 6; 627 | if (utcDay > 6) utcDay = 0; 628 | return utcDay; 629 | } 630 | 631 | $scope.padZero = function(i) { 632 | return i < 10 ? "0" + i : i; 633 | } 634 | 635 | $scope.diff = function(startDate, endDate) { 636 | let diff = endDate - startDate; 637 | const hours = Math.floor(diff / 1000 / 60 / 60); 638 | diff -= hours * 1000 * 60 * 60; 639 | const minutes = Math.floor(diff / 1000 / 60); 640 | 641 | return (hours * 60) + minutes; 642 | } 643 | 644 | $scope.getElement = function(elementId) { 645 | return document.querySelector("#" + elementId + "-" + $scope.nodeId.replace(".", "")); 646 | } 647 | 648 | $scope.changeFilter = function(filter) { 649 | $scope.overviewFilter = filter; 650 | $scope.sendTimersToOutput(); 651 | } 652 | 653 | $scope.getTimersByOverviewFilter = function() { 654 | if ($scope.overviewFilter == 'all') return $scope.timers; 655 | return $scope.timers ? $scope.timers.filter(t => !t.disabled && $scope.isDeviceEnabled(t.output)) : []; 656 | } 657 | 658 | $scope.toggleDeviceStatus = function(deviceIndex) { 659 | if ($scope.isDeviceEnabled(deviceIndex)) { 660 | $scope.disabledDevices = $scope.disabledDevices || []; 661 | $scope.disabledDevices.push(deviceIndex); 662 | } else { 663 | $scope.disabledDevices.splice($scope.disabledDevices.indexOf(deviceIndex), 1); 664 | } 665 | $scope.sendTimersToOutput(); 666 | } 667 | 668 | $scope.isDeviceEnabled = function(deviceIndex) { 669 | const disabledDevices = $scope.disabledDevices || []; 670 | return !disabledDevices.includes(deviceIndex.toString()); 671 | } 672 | 673 | $scope.getTimersFromServer = function() { 674 | $.ajax({ 675 | url: "time-scheduler/getNode/" + $scope.nodeId, dataType: 'json', 676 | beforeSend: function() { 677 | $scope.loading = true; 678 | }, 679 | success: function(json) { 680 | $scope.timers = json.timers; 681 | $scope.disabledDevices = json.settings.disabledDevices; 682 | $scope.overviewFilter = json.settings.overviewFilter; 683 | $scope.$digest(); 684 | }, 685 | complete: function() { 686 | $scope.loading = false; 687 | $scope.showStandardView(); 688 | $scope.$digest(); 689 | } 690 | }); 691 | } 692 | } 693 | }); 694 | 695 | let nodeInterval; 696 | let prevMsg = []; 697 | 698 | (() => { 699 | let timers = getContextValue('timers'); 700 | if (validateTimers(timers)) { 701 | node.status({}); 702 | timers = timers.filter(timer => timer.output < config.devices.length); 703 | } else { 704 | node.status({ fill: "green", shape: "dot", text: "time-scheduler.contextCreated" }); 705 | timers = []; 706 | } 707 | setTimers(timers); 708 | createInitTimeout(); 709 | })(); 710 | 711 | function validateTimers(timers) { 712 | return Array.isArray(timers) && timers.every(element => { 713 | if ((!element.hasOwnProperty("starttime") || !element.hasOwnProperty("days")) || 714 | (!config.eventMode && !element.hasOwnProperty("endtime")) || 715 | (config.eventMode && !element.hasOwnProperty("event"))) return false; 716 | 717 | if (!element.hasOwnProperty("output")) element.output = "0"; 718 | else if (Number.isInteger(element.output)) element.output = element.output.toString(); 719 | 720 | return true; 721 | }); 722 | } 723 | 724 | function getContextValue(key) { 725 | return config.customContextStore && RED.settings.contextStorage && RED.settings.contextStorage.hasOwnProperty(config.customContextStore) ? 726 | node.context().get(key, config.customContextStore) : node.context().get(key); 727 | } 728 | 729 | function setContextValue(key, value) { 730 | config.customContextStore && RED.settings.contextStorage && RED.settings.contextStorage.hasOwnProperty(config.customContextStore) ? 731 | node.context().set(key, value, config.customContextStore) : node.context().set(key, value); 732 | } 733 | 734 | function getTimers() { 735 | const timers = getContextValue('timers') || []; 736 | return updateSolarEvents(timers).sort(function(a, b) { 737 | const millisA = getNowWithCustomTime(a.starttime); 738 | const millisB = getNowWithCustomTime(b.starttime); 739 | return millisA - millisB; 740 | }); 741 | } 742 | 743 | function setTimers(timers) { 744 | setContextValue('timers', timers); 745 | } 746 | 747 | function getSettings() { 748 | return getContextValue('settings') || {}; 749 | } 750 | 751 | function setSettings(settings) { 752 | setContextValue('settings', settings); 753 | } 754 | 755 | function getDisabledDevices() { 756 | return getSettings().disabledDevices || []; 757 | } 758 | 759 | function setDisabledDevices(disabledDevices) { 760 | setSettings({ ...getSettings(), disabledDevices }); 761 | } 762 | 763 | function addDisabledDevice(device) { 764 | const disabledDevices = getDisabledDevices(); 765 | const deviceIndex = (isNaN(device) ? config.devices.indexOf(device) : device).toString(); 766 | if (deviceIndex >= 0 && config.devices.length > deviceIndex && !disabledDevices.includes(deviceIndex)) { 767 | disabledDevices.push(deviceIndex); 768 | setDisabledDevices(disabledDevices); 769 | return true; 770 | } 771 | return false; 772 | } 773 | 774 | function removeDisabledDevice(device) { 775 | const disabledDevices = getDisabledDevices(); 776 | const deviceIndex = (isNaN(device) ? config.devices.indexOf(device) : device).toString(); 777 | if (deviceIndex >= 0 && config.devices.length > deviceIndex && disabledDevices.includes(deviceIndex)) { 778 | disabledDevices.splice(disabledDevices.indexOf(deviceIndex), 1); 779 | setDisabledDevices(disabledDevices); 780 | return true; 781 | } 782 | return false; 783 | } 784 | 785 | function createInitTimeout() { 786 | const today = new Date(); 787 | const remaining = config.refresh - (today.getSeconds() % config.refresh); 788 | setTimeout(function() { 789 | nodeInterval = setInterval(intervalTimerFunction, config.refresh * 1000); 790 | intervalTimerFunction(); 791 | }, (remaining * 1000) - today.getMilliseconds()); 792 | } 793 | 794 | function intervalTimerFunction() { 795 | const outputValues = [null]; 796 | addOutputValues(outputValues); 797 | node.send(outputValues); 798 | } 799 | 800 | function addOutputValues(outputValues) { 801 | for (let device = 0; device < config.devices.length; device++) { 802 | const msg = { payload: isInTime(device) }; 803 | if (config.sendTopic) msg.topic = config.devices[device]; 804 | msg.payload != null ? outputValues.push(msg) : outputValues.push(null); 805 | } 806 | if (config.onlySendChange) removeUnchangedValues(outputValues); 807 | } 808 | 809 | function removeUnchangedValues(outputValues) { 810 | const currMsg = JSON.parse(JSON.stringify(outputValues)); 811 | for (let i = 1; i <= config.devices.length; i++) { 812 | if (prevMsg[i] && currMsg[i] && (prevMsg[i].payload === currMsg[i].payload)) { 813 | outputValues[i] = null; 814 | } 815 | } 816 | prevMsg = currMsg; 817 | } 818 | 819 | function isInTime(deviceIndex) { 820 | const nodeTimers = getTimers(); 821 | let status = null; 822 | 823 | if (nodeTimers.length > 0 && !getDisabledDevices().includes(deviceIndex.toString())) { 824 | const date = new Date(); 825 | 826 | nodeTimers.filter(timer => timer.output == deviceIndex).forEach(function(timer) { 827 | if (status != null) return; 828 | if (timer.hasOwnProperty("disabled")) return; 829 | 830 | const utcDay = localDayToUtc(timer, date.getDay()); 831 | const localStarttime = new Date(timer.starttime); 832 | const localEndtime = config.eventMode ? localStarttime : new Date(timer.endtime); 833 | const daysDiff = localEndtime.getDay() - localStarttime.getDay(); 834 | 835 | if (daysDiff != 0) { 836 | // WRAPS AROUND MIDNIGHT (SERVER PERSPECTIVE) 837 | const utcYesterday = utcDay - 1 < 0 ? 6 : utcDay - 1; 838 | if (timer.days[utcYesterday] === 1) { 839 | // AND STARTED YESTERDAY (SERVER PERSPECTIVE) 840 | const compareDate = new Date(localEndtime); 841 | compareDate.setHours(date.getHours()); 842 | compareDate.setMinutes(date.getMinutes()); 843 | if (compareDate.getTime() < localEndtime.getTime()) { 844 | status = true; 845 | return; 846 | } 847 | } 848 | } 849 | 850 | if (timer.days[utcDay] === 0) return; 851 | 852 | const compareDate = new Date(localStarttime); 853 | compareDate.setHours(date.getHours()); 854 | compareDate.setMinutes(date.getMinutes()); 855 | 856 | if (config.eventMode) { 857 | if (compareDate.getTime() == localStarttime.getTime()) { 858 | status = timer.event; 859 | } 860 | } else { 861 | if (compareDate.getTime() >= localStarttime.getTime() && compareDate.getTime() < localEndtime.getTime()) { 862 | status = true; 863 | } else if (compareDate.getTime() == localEndtime.getTime()) { 864 | status = false; 865 | } 866 | } 867 | }); 868 | } 869 | 870 | if (!config.eventMode && !config.singleOff && status == null) status = false; 871 | return status; 872 | } 873 | 874 | function localDayToUtc(timer, localDay) { 875 | const start = new Date(timer.starttime); 876 | let shift = start.getUTCDay() - start.getDay(); 877 | if (shift < -1) shift = 1; 878 | if (shift > 1) shift = -1; 879 | let utcDay = shift + localDay; 880 | if (utcDay < 0) utcDay = 6; 881 | if (utcDay > 6) utcDay = 0; 882 | return utcDay; 883 | } 884 | 885 | function getNowWithCustomTime(timeInMillis) { 886 | const date = new Date(); 887 | const origDate = new Date(timeInMillis); 888 | date.setHours(origDate.getHours()); 889 | date.setMinutes(origDate.getMinutes()); 890 | date.setSeconds(0); date.setMilliseconds(0); 891 | return date.getTime(); 892 | } 893 | 894 | function updateSolarEvents(timers) { 895 | if (config.solarEventsEnabled) { 896 | const sunTimes = sunCalc.getTimes(new Date(), config.lat, config.lon); 897 | return timers.map(t => { 898 | if (t.hasOwnProperty("startSolarEvent")) { 899 | const offset = t.startSolarOffset || 0; 900 | const solarTime = sunTimes[t.startSolarEvent]; 901 | t.starttime = solarTime.getTime() + (offset * 60 * 1000); 902 | } 903 | if (t.hasOwnProperty("endSolarEvent")) { 904 | const offset = t.endSolarOffset || 0; 905 | const solarTime = sunTimes[t.endSolarEvent]; 906 | t.endtime = solarTime.getTime() + (offset * 60 * 1000); 907 | } 908 | if (t.hasOwnProperty("startSolarEvent") || t.hasOwnProperty("endSolarEvent")) { 909 | if (t.starttime >= t.endtime) t.endtime += 24 * 60 * 60 * 1000; 910 | } 911 | return t; 912 | }); 913 | } else { 914 | return timers.filter(t => !t.hasOwnProperty("startSolarEvent") && !t.hasOwnProperty("endSolarEvent")); 915 | } 916 | } 917 | 918 | function getNodeData() { 919 | return { timers: getTimers(), settings: getSettings() }; 920 | } 921 | 922 | function serializeData() { 923 | return JSON.stringify(getNodeData()); 924 | } 925 | 926 | node.nodeCallback = function nodeCallback(req, res) { 927 | res.send(getNodeData()); 928 | } 929 | 930 | node.on("close", function() { 931 | if (nodeInterval) { 932 | clearInterval(nodeInterval); 933 | } 934 | if (done) { 935 | done(); 936 | } 937 | }); 938 | } 939 | } catch (error) { 940 | console.log("TimeSchedulerNode:", error); 941 | } 942 | } 943 | RED.nodes.registerType("ui_time_scheduler", TimeSchedulerNode); 944 | 945 | let uiPath = ((RED.settings.ui || {}).path); 946 | if (uiPath == undefined) uiPath = 'ui'; 947 | let nodePath = '/' + uiPath + '/time-scheduler/getNode/:nodeId'; 948 | nodePath = nodePath.replace(/\/+/g, '/'); 949 | 950 | RED.httpNode.get(nodePath, function(req, res) { 951 | const nodeId = req.params.nodeId; 952 | const node = RED.nodes.getNode(nodeId); 953 | node ? node.nodeCallback(req, res) : res.send(404).end(); 954 | }); 955 | } --------------------------------------------------------------------------------