node-red-contrib-power-saver 3.6.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/.eslintrc.js +15 -0
  2. package/docs/.vuepress/components/DonateButtons.vue +26 -3
  3. package/docs/.vuepress/components/VippsPlakat.vue +20 -0
  4. package/docs/.vuepress/config.js +17 -10
  5. package/docs/.vuepress/public/ads.txt +1 -0
  6. package/docs/README.md +4 -4
  7. package/docs/changelog/README.md +55 -1
  8. package/docs/contribute/README.md +8 -3
  9. package/docs/examples/example-grid-tariff-capacity-flow.json +23 -7
  10. package/docs/examples/example-grid-tariff-capacity-part.md +657 -22
  11. package/docs/faq/README.md +1 -1
  12. package/docs/faq/best-save-viewer.md +1 -1
  13. package/docs/guide/README.md +20 -5
  14. package/docs/images/best-save-config.png +0 -0
  15. package/docs/images/combine-two-lowest-price.png +0 -0
  16. package/docs/images/fixed-schedule-config.png +0 -0
  17. package/docs/images/global-context-window.png +0 -0
  18. package/docs/images/lowest-price-config.png +0 -0
  19. package/docs/images/node-ps-schedule-merger.png +0 -0
  20. package/docs/images/node-ps-strategy-fixed-schedule.png +0 -0
  21. package/docs/images/ps-strategy-fixed-schedule-example.png +0 -0
  22. package/docs/images/schedule-merger-config.png +0 -0
  23. package/docs/images/schedule-merger-example-1.png +0 -0
  24. package/docs/images/vipps-plakat.png +0 -0
  25. package/docs/images/vipps-qr.png +0 -0
  26. package/docs/images/vipps-smiling-rgb-orange-pos.png +0 -0
  27. package/docs/nodes/README.md +12 -6
  28. package/docs/nodes/dynamic-commands.md +79 -0
  29. package/docs/nodes/dynamic-config.md +76 -0
  30. package/docs/nodes/ps-elvia-add-tariff.md +4 -0
  31. package/docs/nodes/ps-general-add-tariff.md +10 -0
  32. package/docs/nodes/ps-receive-price.md +2 -1
  33. package/docs/nodes/ps-schedule-merger.md +227 -0
  34. package/docs/nodes/ps-strategy-best-save.md +46 -110
  35. package/docs/nodes/ps-strategy-fixed-schedule.md +101 -0
  36. package/docs/nodes/ps-strategy-heat-capacitor.md +6 -1
  37. package/docs/nodes/ps-strategy-lowest-price.md +51 -112
  38. package/package.json +5 -2
  39. package/src/elvia/elvia-add-tariff.html +1 -2
  40. package/src/elvia/elvia-add-tariff.js +0 -1
  41. package/src/elvia/elvia-api.js +6 -0
  42. package/src/elvia/elvia-tariff.html +1 -1
  43. package/src/general-add-tariff.html +14 -8
  44. package/src/general-add-tariff.js +0 -1
  45. package/src/handle-input.js +94 -106
  46. package/src/handle-output.js +109 -0
  47. package/src/receive-price-functions.js +3 -3
  48. package/src/schedule-merger-functions.js +98 -0
  49. package/src/schedule-merger.html +135 -0
  50. package/src/schedule-merger.js +108 -0
  51. package/src/strategy-best-save.html +38 -1
  52. package/src/strategy-best-save.js +17 -63
  53. package/src/strategy-fixed-schedule.html +339 -0
  54. package/src/strategy-fixed-schedule.js +84 -0
  55. package/src/strategy-functions.js +35 -0
  56. package/src/strategy-lowest-price.html +76 -38
  57. package/src/strategy-lowest-price.js +16 -35
  58. package/src/utils.js +75 -2
  59. package/test/commands-input-best-save.test.js +142 -0
  60. package/test/commands-input-lowest-price.test.js +149 -0
  61. package/test/commands-input-schedule-merger.test.js +128 -0
  62. package/test/data/best-save-overlap-result.json +5 -1
  63. package/test/data/best-save-result.json +4 -0
  64. package/test/data/commands-result-best-save.json +383 -0
  65. package/test/data/commands-result-lowest-price.json +340 -0
  66. package/test/data/fixed-schedule-result.json +353 -0
  67. package/test/data/lowest-price-result-cont-max-fail.json +5 -1
  68. package/test/data/lowest-price-result-cont-max.json +3 -1
  69. package/test/data/lowest-price-result-cont.json +8 -1
  70. package/test/data/lowest-price-result-missing-end.json +8 -3
  71. package/test/data/lowest-price-result-neg-cont.json +27 -0
  72. package/test/data/lowest-price-result-neg-split.json +23 -0
  73. package/test/data/lowest-price-result-split-allday.json +3 -1
  74. package/test/data/lowest-price-result-split-allday10.json +1 -0
  75. package/test/data/lowest-price-result-split-max.json +3 -1
  76. package/test/data/lowest-price-result-split.json +3 -1
  77. package/test/data/merge-schedule-data.js +238 -0
  78. package/test/data/negative-prices.json +197 -0
  79. package/test/data/nordpool-event-prices.json +96 -480
  80. package/test/data/nordpool-zero-prices.json +90 -0
  81. package/test/data/reconfigResult.js +1 -0
  82. package/test/data/result.js +1 -0
  83. package/test/data/tibber-result-end-0-24h.json +12 -2
  84. package/test/data/tibber-result-end-0.json +12 -2
  85. package/test/data/tibber-result.json +1 -0
  86. package/test/receive-price.test.js +22 -0
  87. package/test/schedule-merger-functions.test.js +101 -0
  88. package/test/schedule-merger-test-utils.js +27 -0
  89. package/test/schedule-merger.test.js +130 -0
  90. package/test/send-config-input.test.js +45 -2
  91. package/test/strategy-best-save-test-utils.js +1 -1
  92. package/test/strategy-best-save.test.js +45 -0
  93. package/test/strategy-fixed-schedule.test.js +117 -0
  94. package/test/strategy-heat-capacitor.test.js +1 -1
  95. package/test/strategy-lowest-price-functions.test.js +1 -1
  96. package/test/strategy-lowest-price-test-utils.js +31 -0
  97. package/test/strategy-lowest-price.test.js +55 -45
  98. package/test/test-utils.js +43 -36
  99. package/test/utils.test.js +13 -0
  100. package/docs/images/node-power-saver.png +0 -0
  101. package/docs/nodes/power-saver.md +0 -23
  102. package/src/power-saver.html +0 -116
  103. package/src/power-saver.js +0 -260
  104. package/test/commands-input.test.js +0 -47
  105. package/test/power-saver.test.js +0 -189
@@ -1,7 +1,5 @@
1
1
  <script type="text/javascript">
2
- const dateRe = /^$|^\d{4}\-(0[1-9]|1[012])\-(0[1-9]|[12][0-9]|3[01])$/;
3
- const priceRe = /^(\d+\.\d*)|(\d+)$/;
4
- const defaultDays = { Mon: true, Tue: true, Wed: true, Thu: true, Fri: true, Sat: true, Sun: true };
2
+ const defaultDaysGat = { Mon: true, Tue: true, Wed: true, Thu: true, Fri: true, Sat: true, Sun: true };
5
3
  RED.nodes.registerType("ps-general-add-tariff", {
6
4
  category: "Power Saver",
7
5
  color: "#a6bbcf",
@@ -13,12 +11,20 @@
13
11
  { start: "06", value: 0.0 },
14
12
  ],
15
13
  validate: function () {
16
- return !this.periods.some((p) => !priceRe.test("" + p.value));
14
+ return !this.periods.some((p) => !/^(\d+\.\d*)|(\d+)$/.test("" + p.value));
17
15
  },
18
16
  },
19
- validFrom: { value: null, required: false, validate: RED.validators.regex(dateRe) },
20
- validTo: { value: null, required: false, validate: RED.validators.regex(dateRe) },
21
- days: { value: { ...defaultDays } },
17
+ validFrom: {
18
+ value: null,
19
+ required: false,
20
+ validate: RED.validators.regex(/^$|^\d{4}\-(0[1-9]|1[012])\-(0[1-9]|[12][0-9]|3[01])$/),
21
+ },
22
+ validTo: {
23
+ value: null,
24
+ required: false,
25
+ validate: RED.validators.regex(/^$|^\d{4}\-(0[1-9]|1[012])\-(0[1-9]|[12][0-9]|3[01])$/),
26
+ },
27
+ days: { value: { ...defaultDaysGat } },
22
28
  },
23
29
  hours: [
24
30
  "00",
@@ -187,7 +193,7 @@
187
193
 
188
194
  if (!this.days) {
189
195
  // To support nodes created before this was developed
190
- this.days = { ...defaultDays };
196
+ this.days = { ...defaultDaysGat };
191
197
  }
192
198
  drawDays(this.days);
193
199
  },
@@ -5,7 +5,6 @@ const { getEffectiveConfig } = require("./utils");
5
5
  module.exports = function (RED) {
6
6
  function PsGeneralAddTariffNode(config) {
7
7
  RED.nodes.createNode(this, config);
8
- this.range = config.range;
9
8
  const node = this;
10
9
 
11
10
  const originalConfig = {
@@ -1,22 +1,14 @@
1
- const { extractPlanForDate, getEffectiveConfig, validationFailure } = require("./utils");
1
+ const { extractPlanForDate, loadDayData, makeSchedule, msgHasPriceData, validationFailure } = require("./utils");
2
2
  const { DateTime } = require("luxon");
3
- const { version } = require("../package.json");
4
-
5
- function handleStrategyInput(node, msg, doPlanning) {
6
- const effectiveConfig = getEffectiveConfig(node, msg);
7
- // Store config variables in node
8
- Object.keys(effectiveConfig).forEach((key) => (node[key] = effectiveConfig[key]));
9
3
 
4
+ function handleStrategyInput(node, msg, config, doPlanning, calcSavings) {
10
5
  if (!validateInput(node, msg)) {
11
6
  return;
12
7
  }
13
- if (msg.payload.commands && !anyLegalCommands(msg.payload.commands)) {
14
- const message = "Illegal command";
15
- node.warn(message);
16
- node.status({ fill: "yellow", shape: "dot", text: message });
17
- return;
18
- }
19
- if (msg.payload.commands && msg.payload.commands.reset) {
8
+
9
+ const commands = getCommands(msg);
10
+
11
+ if (commands.reset) {
20
12
  node.warn("Resetting node context by command");
21
13
  // Reset all saved data
22
14
  node
@@ -25,118 +17,122 @@ function handleStrategyInput(node, msg, doPlanning) {
25
17
  deleteSavedScheduleBefore(node, DateTime.now().plus({ days: 2 }), 100);
26
18
  }
27
19
 
28
- let { priceData, source } = getPriceData(node, msg);
29
- if (!priceData) {
30
- // Use last saved price data
31
- priceData = node.context().get("lastPriceData", node.contextStorage);
32
- source = node.context().get("lastSource", node.contextStorage);
33
- const message = "Using saved prices";
34
- node.warn(message);
35
- node.status({ fill: "green", shape: "ring", text: message });
36
- }
37
- if (!priceData) {
20
+ const plan =
21
+ msgHasPriceData(msg) || config.hasChanged
22
+ ? makePlanFromPriceData(node, msg, config, doPlanning, calcSavings)
23
+ : node.context().get("lastPlan", node.contextStorage);
24
+
25
+ // If still no plan?
26
+ if (!plan) {
38
27
  const message = "No price data";
39
28
  node.warn(message);
40
29
  node.status({ fill: "yellow", shape: "dot", text: message });
41
30
  return;
42
31
  }
43
- const planFromTime = msg.payload.time ? DateTime.fromISO(msg.payload.time) : DateTime.now();
44
32
 
45
- clearTimeout(node.schedulingTimeout);
33
+ return { plan, commands };
34
+ }
35
+
36
+ function makePlanFromPriceData(node, msg, config, doPlanning, calcSavings) {
37
+ const { priceData, source } = msgHasPriceData(msg) ? getPriceDataFromMessage(msg) : getSavedLastPriceData(node);
38
+ if (msgHasPriceData(msg)) {
39
+ saveLastPriceData(node, priceData, source);
40
+ }
41
+
42
+ if (!priceData) {
43
+ return null;
44
+ }
46
45
 
47
46
  const dates = [...new Set(priceData.map((v) => DateTime.fromISO(v.start).toISODate()))];
48
47
 
49
48
  // Load data from day before
50
- const dateToday = DateTime.fromISO(dates[0]);
51
49
  const dateDayBefore = DateTime.fromISO(dates[0]).plus({ days: -1 });
50
+ const dataDayBefore = loadDataJustBefore(node, dateDayBefore);
51
+ const priceDataDayBefore = dataDayBefore.hours.map((h) => ({ value: h.price, start: h.start }));
52
+ const priceDataWithDayBefore = [...priceDataDayBefore, ...priceData];
52
53
 
53
54
  // Make plan
54
- const plan = doPlanning(node, effectiveConfig, priceData, planFromTime, dateDayBefore, dateToday);
55
+ const startTimes = priceDataWithDayBefore.map((d) => d.start);
56
+ const prices = priceDataWithDayBefore.map((d) => d.value);
57
+ const onOff = doPlanning(node, priceDataWithDayBefore);
58
+ const savings = calcSavings(prices, onOff);
59
+ const hours = startTimes.map((v, i) => ({
60
+ start: startTimes[i],
61
+ price: prices[i],
62
+ onOff: onOff[i],
63
+ saving: savings[i],
64
+ }));
65
+ const schedule = makeSchedule(onOff, startTimes);
66
+ addLastSwitchIfNoSchedule(schedule, hours, config);
67
+
68
+ plan = {
69
+ hours,
70
+ schedule,
71
+ source,
72
+ };
55
73
 
56
74
  // Save schedule
57
75
  node.context().set("lastPlan", plan, node.contextStorage);
58
76
  dates.forEach((d) => saveDayData(node, d, extractPlanForDate(plan, d)));
59
77
 
60
- const sentOnCommand = !!msg.payload.commands?.sendSchedule;
61
-
62
- // Prepare output
63
- let output1 = null;
64
- let output2 = null;
65
- let output3 = {
66
- payload: {
67
- schedule: plan.schedule,
68
- hours: plan.hours,
69
- source,
70
- config: effectiveConfig,
71
- sentOnCommand,
72
- time: planFromTime.toISO(),
73
- version,
74
- },
75
- };
76
-
77
- // Find current output, and set output (if configured to do)
78
- const pastSchedule = plan.schedule.filter((entry) => DateTime.fromISO(entry.time) <= planFromTime);
79
-
80
- const sendNow = !!node.sendCurrentValueWhenRescheduling && pastSchedule.length > 0 && !sentOnCommand;
81
- const currentValue = pastSchedule[pastSchedule.length - 1]?.value;
82
- if (sendNow || !!msg.payload.commands?.sendOutput) {
83
- output1 = currentValue ? { payload: true } : null;
84
- output2 = currentValue ? null : { payload: false };
85
- }
86
- output3.payload.current = currentValue;
87
-
88
78
  // Delete old data
89
79
  deleteSavedScheduleBefore(node, dateDayBefore);
90
80
 
91
- // Send output
92
- node.send([output1, output2, output3]);
93
-
94
- // Run schedule
95
- node.schedulingTimeout = runSchedule(node, plan.schedule, planFromTime, sendNow);
81
+ return plan;
96
82
  }
97
83
 
98
- function getPriceData(node, msg) {
99
- const isConfigMsg = !!msg?.payload?.config;
100
- const isCommandMsg = !!msg?.payload?.commands;
101
- const isPriceMsg = !!msg?.payload?.priceData;
102
- if ((isConfigMsg || isCommandMsg) && !isPriceMsg) {
103
- const priceData = node.context().get("lastPriceData", node.contextStorage);
104
- const source = node.context().get("lastSource", node.contextStorage);
105
- return { priceData, source };
84
+ // Commands
85
+
86
+ function getCommands(msg) {
87
+ const legalCommands = ["reset", "replan", "sendOutput", "sendSchedule"];
88
+ const commands = { legal: true };
89
+ if (!msg?.payload?.commands) {
90
+ return commands;
106
91
  }
92
+ legalCommands.forEach((c) => {
93
+ commands[c] = msg.payload.commands[c];
94
+ });
95
+ return commands;
96
+ }
97
+
98
+ // Price data
99
+
100
+ function getPriceDataFromMessage(msg) {
107
101
  const priceData = msg.payload.priceData;
108
102
  const source = msg.payload.source;
103
+ return { priceData, source };
104
+ }
105
+
106
+ function getSavedLastPriceData(node) {
107
+ const priceData = node.context().get("lastPriceData", node.contextStorage);
108
+ const source = node.context().get("lastSource", node.contextStorage);
109
+ return { priceData, source };
110
+ }
111
+
112
+ function saveLastPriceData(node, priceData, source) {
109
113
  node.context().set("lastPriceData", priceData, node.contextStorage);
110
114
  node.context().set("lastSource", source, node.contextStorage);
111
- return { priceData, source };
112
115
  }
113
116
 
114
- function runSchedule(node, schedule, time, currentSent = false) {
115
- let remainingSchedule = schedule.filter((entry) => {
116
- return DateTime.fromISO(entry.time) > time;
117
- });
118
- if (remainingSchedule.length > 0) {
119
- const entry = remainingSchedule[0];
120
- const nextTime = DateTime.fromISO(entry.time);
121
- const wait = nextTime - time;
122
- const onOff = entry.value ? "on" : "off";
123
- node.log("Switching " + onOff + " in " + wait + " milliseconds");
124
- const statusMessage = `${remainingSchedule.length} changes - ${
125
- remainingSchedule[0].value ? "on" : "off"
126
- } at ${nextTime.toLocaleString(DateTime.TIME_SIMPLE)}`;
127
- node.status({ fill: "green", shape: "dot", text: statusMessage });
128
- return setTimeout(() => {
129
- sendSwitch(node, entry.value);
130
- node.schedulingTimeout = runSchedule(node, remainingSchedule, nextTime);
131
- }, wait);
132
- } else {
133
- const message = "No schedule";
134
- node.warn(message);
135
- node.status({ fill: "red", shape: "dot", text: message });
136
- if (!currentSent) {
137
- sendSwitch(node, node.outputIfNoSchedule);
138
- }
117
+ // Other
118
+
119
+ function addLastSwitchIfNoSchedule(schedule, hours, config) {
120
+ if (!hours.length) {
121
+ return;
139
122
  }
123
+ if (schedule.length > 0 && schedule[schedule.length - 1].value === config.outputIfNoSchedule) {
124
+ return;
125
+ }
126
+ const nextHour = DateTime.fromISO(hours[hours.length - 1].start).plus({ hours: 1 });
127
+ schedule.push({ time: nextHour.toISO(), value: config.outputIfNoSchedule, countHours: null });
128
+ }
129
+
130
+ function loadDataJustBefore(node, dateDayBefore) {
131
+ const dataDayBefore = loadDayData(node, dateDayBefore);
132
+ return {
133
+ schedule: [...dataDayBefore.schedule],
134
+ hours: [...dataDayBefore.hours],
135
+ };
140
136
  }
141
137
 
142
138
  function deleteSavedScheduleBefore(node, day, checkDays = 0) {
@@ -155,12 +151,6 @@ function saveDayData(node, date, plan) {
155
151
  node.context().set(date, plan, node.contextStorage);
156
152
  }
157
153
 
158
- function sendSwitch(node, onOff) {
159
- const output1 = onOff ? { payload: true } : null;
160
- const output2 = onOff ? null : { payload: false };
161
- node.send([output1, output2, null]);
162
- }
163
-
164
154
  function validateInput(node, msg) {
165
155
  if (!msg.payload) {
166
156
  validationFailure(node, "No payload");
@@ -189,7 +179,7 @@ function validateInput(node, msg) {
189
179
  return;
190
180
  }
191
181
  msg.payload.priceData.forEach((h) => {
192
- if (!h.start || !h.value) {
182
+ if (!h.start || isNaN(h.value)) {
193
183
  validationFailure(node, "Malformed entries in priceData. All entries must contain start and value.");
194
184
  return;
195
185
  }
@@ -197,11 +187,9 @@ function validateInput(node, msg) {
197
187
  return true;
198
188
  }
199
189
 
200
- function anyLegalCommands(commands) {
201
- return ["reset", "replan", "sendOutput", "sendSchedule"].some((v) => commands.hasOwnProperty(v));
202
- }
203
-
204
190
  module.exports = {
191
+ addLastSwitchIfNoSchedule,
192
+ getCommands,
205
193
  handleStrategyInput,
206
194
  validateInput,
207
195
  };
@@ -0,0 +1,109 @@
1
+ const { DateTime } = require("luxon");
2
+ const { version } = require("../package.json");
3
+ const { getOutputForTime, msgHasConfig, msgHasPriceData } = require("./utils.js");
4
+
5
+ function handleOutput(node, config, plan, outputCommands, planFromTime) {
6
+ /*
7
+ The plan received here must contain previous schedule so current value can be sent.
8
+
9
+ Functions to perform is in the outputCommands object:
10
+ sendOutput: Send current output on either output 1 or 2 (on or off).
11
+ sendSchedule: Send current schedule on output 3.
12
+ runSchedule: Reset schedule and run it for remaining plan.
13
+
14
+ */
15
+
16
+ // Prepare output
17
+ let output3 = {
18
+ payload: {
19
+ schedule: plan.schedule,
20
+ hours: plan.hours,
21
+ source: plan.source,
22
+ config,
23
+ time: planFromTime.toISO(),
24
+ version,
25
+ strategyNodeId: node.id,
26
+ },
27
+ };
28
+
29
+ // Find current output, and set output (if configured to do)
30
+ const currentValue =
31
+ node.override === "auto"
32
+ ? getOutputForTime(plan.schedule, planFromTime, node.outputIfNoSchedule)
33
+ : node.override === "on";
34
+ output3.payload.current = currentValue;
35
+
36
+ // Send output
37
+ if (outputCommands.sendOutput) {
38
+ sendSwitch(node, currentValue);
39
+ }
40
+
41
+ // Send schedule
42
+ if (outputCommands.sendSchedule) {
43
+ node.send([null, null, output3]);
44
+ }
45
+
46
+ // Run schedule
47
+ if (outputCommands.runSchedule) {
48
+ clearTimeout(node.schedulingTimeout);
49
+ node.schedulingTimeout = runSchedule(node, plan.schedule, planFromTime, true);
50
+ }
51
+ }
52
+
53
+ function sendSwitch(node, onOff) {
54
+ const output1 = onOff ? { payload: true } : null;
55
+ const output2 = onOff ? null : { payload: false };
56
+ node.send([output1, output2, null]);
57
+ node.context().set("currentOutput", onOff);
58
+ }
59
+
60
+ function runSchedule(node, schedule, time, currentSent = false) {
61
+ let remainingSchedule = schedule.filter((entry) => {
62
+ return DateTime.fromISO(entry.time) > time;
63
+ });
64
+ if (remainingSchedule.length > 0) {
65
+ const entry = remainingSchedule[0];
66
+ const nextTime = DateTime.fromISO(entry.time);
67
+ const wait = nextTime - time;
68
+ const onOff = entry.value ? "on" : "off";
69
+ node.log("Switching " + onOff + " in " + wait + " milliseconds");
70
+ const statusMessage = `${remainingSchedule.length} changes - ${
71
+ remainingSchedule[0].value ? "on" : "off"
72
+ } at ${nextTime.toLocaleString(DateTime.TIME_SIMPLE)}`;
73
+ node.status({ fill: "green", shape: "dot", text: statusMessage });
74
+ return setTimeout(() => {
75
+ sendSwitch(node, entry.value);
76
+ node.schedulingTimeout = runSchedule(node, remainingSchedule, nextTime);
77
+ }, wait);
78
+ } else {
79
+ const message = "No schedule";
80
+ node.warn(message);
81
+ node.status({ fill: "yellow", shape: "dot", text: message });
82
+ if (!currentSent) {
83
+ sendSwitch(node, node.outputIfNoSchedule);
84
+ }
85
+ }
86
+ }
87
+
88
+ function shallSendOutput(msg, commands, currentOutput, plannedOutputNow, sendCurrentValueWhenRescheduling) {
89
+ if (commands.sendOutput !== undefined) {
90
+ return commands.sendOutput;
91
+ }
92
+ if (msgHasConfig(msg) || msgHasPriceData(msg) || commands.replan) {
93
+ return sendCurrentValueWhenRescheduling ? true : currentOutput !== plannedOutputNow;
94
+ }
95
+ return false;
96
+ }
97
+
98
+ function strategyShallSendSchedule(msg, commands) {
99
+ if (commands.sendSchedule !== undefined) {
100
+ return commands.sendSchedule;
101
+ }
102
+ return msgHasConfig(msg) || msgHasPriceData(msg) || commands.replan;
103
+ }
104
+
105
+ module.exports = {
106
+ handleOutput,
107
+ shallSendOutput,
108
+ strategyShallSendSchedule,
109
+ };
@@ -61,7 +61,7 @@ function convertMsg(msg) {
61
61
  } else if (msg.data?.new_state?.attributes["raw_" + day]) {
62
62
  result.source = "Nordpool";
63
63
  result[day] = msg.data.new_state.attributes["raw_" + day]
64
- .filter((v) => v.value)
64
+ .filter((v) => v.value !== undefined && v.value !== null)
65
65
  .map((v) => ({
66
66
  value: v.value,
67
67
  start: v.start,
@@ -69,7 +69,7 @@ function convertMsg(msg) {
69
69
  } else if (msg.data?.attributes && msg.data?.attributes["raw_" + day]) {
70
70
  result.source = "Nordpool";
71
71
  result[day] = msg.data.attributes["raw_" + day]
72
- .filter((v) => v.value)
72
+ .filter((v) => v.value !== undefined && v.value !== null)
73
73
  .map((v) => ({
74
74
  value: v.value,
75
75
  start: v.start,
@@ -77,7 +77,7 @@ function convertMsg(msg) {
77
77
  } else if (msg.payload?.attributes && msg.payload.attributes["raw_" + day]) {
78
78
  result.source = "Nordpool";
79
79
  result[day] = msg.payload.attributes["raw_" + day]
80
- .filter((v) => v.value)
80
+ .filter((v) => v.value !== undefined && v.value !== null)
81
81
  .map((v) => ({
82
82
  value: v.value,
83
83
  start: v.start,
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+
3
+ const { msgHasConfig } = require("./utils.js");
4
+
5
+ function msgHasSchedule(msg) {
6
+ return msg.payload.hours?.length > 0;
7
+ }
8
+
9
+ function validateSchedule(msg) {
10
+ return "";
11
+ }
12
+
13
+ function saveSchedule(node, msg) {
14
+ let savedSchedules = node.context().get("savedSchedules") || {};
15
+
16
+ // If the saved schedule has a different start period, delete them
17
+ const ids = Object.keys(savedSchedules);
18
+ if (ids.length) {
19
+ const lastSaved = savedSchedules[ids[0]].hours.length - 1;
20
+ const lastNew = msg.payload.hours.length - 1;
21
+ if (
22
+ savedSchedules[ids[0]].hours[0].start !== msg.payload.hours[0].start ||
23
+ savedSchedules[ids[0]].hours[lastSaved].start !== msg.payload.hours[lastNew].start
24
+ ) {
25
+ node.warn("Got schedule with different time. Deleting existing schedules.");
26
+ savedSchedules = {};
27
+ }
28
+ }
29
+
30
+ const id = msg.payload.strategyNodeId;
31
+ savedSchedules[id] = msg.payload;
32
+ node.context().set("savedSchedules", savedSchedules);
33
+ }
34
+
35
+ function mergeSchedules(node, logicFunction) {
36
+ // Transpose all schedules
37
+ const transposed = {};
38
+ const savedSchedules = node.context().get("savedSchedules");
39
+ if (!savedSchedules) {
40
+ const msg = "No schedules";
41
+ node.warn(msg);
42
+ node.status({ fill: "red", shape: "dot", text: msg });
43
+ return [];
44
+ }
45
+ const sourceNodes = Object.keys(savedSchedules);
46
+ sourceNodes.forEach((strategyNodeId) => {
47
+ const hours = savedSchedules[strategyNodeId].hours;
48
+ hours.forEach((hour) => {
49
+ if (!Object.hasOwn(transposed, hour.start)) {
50
+ transposed[hour.start] = {};
51
+ }
52
+ transposed[hour.start][strategyNodeId] = { hour };
53
+ });
54
+ });
55
+
56
+ // Sort keys on start time
57
+ const sortedHours = Object.keys(transposed).sort((a, b) => (a > b ? 1 : a === b ? 0 : -1));
58
+
59
+ // Merge
60
+ const mergedHours = sortedHours.map((start) => {
61
+ const sources = transposed[start];
62
+ const onOff =
63
+ logicFunction === "OR"
64
+ ? Object.keys(sources).some((s) => sources[s].hour.onOff)
65
+ : Object.keys(sources).every((s) => sources[s].hour.onOff);
66
+ const price = sources[Object.keys(sources)[0]].hour.price;
67
+ const saving = null;
68
+ const res = { start, onOff, sources, price, saving };
69
+ return res;
70
+ });
71
+ return mergedHours;
72
+ }
73
+
74
+ function mergerShallSendSchedule(msg, commands) {
75
+ if (commands.sendSchedule !== undefined) {
76
+ return commands.sendSchedule;
77
+ }
78
+ return msgHasConfig(msg) || msgHasSchedule(msg) || commands.replan;
79
+ }
80
+
81
+ function mergerShallSendOutput(msg, commands, currentOutput, plannedOutputNow, sendCurrentValueWhenRescheduling) {
82
+ if (commands.sendOutput !== undefined) {
83
+ return commands.sendOutput;
84
+ }
85
+ if (msgHasConfig(msg) || msgHasSchedule(msg) || commands.replan) {
86
+ return sendCurrentValueWhenRescheduling ? true : currentOutput !== plannedOutputNow;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ module.exports = {
92
+ msgHasSchedule,
93
+ validateSchedule,
94
+ saveSchedule,
95
+ mergeSchedules,
96
+ mergerShallSendOutput,
97
+ mergerShallSendSchedule,
98
+ };
@@ -0,0 +1,135 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType("ps-schedule-merger", {
3
+ category: "Power Saver",
4
+ color: "#a6bbcf",
5
+ defaults: {
6
+ name: { value: "Schedule Merger" },
7
+ outputIfNoSchedule: {
8
+ value: true,
9
+ required: true,
10
+ align: "left",
11
+ },
12
+ logicFunction: { value: "OR", required: true, align: "left" },
13
+ schedulingDelay: {
14
+ value: 2000,
15
+ required: true,
16
+ validate: RED.validators.number(),
17
+ },
18
+ sendCurrentValueWhenRescheduling: {
19
+ value: true,
20
+ required: true,
21
+ align: "left",
22
+ },
23
+ outputValueForOn: {
24
+ value: true,
25
+ required: true,
26
+ validate: RED.validators.typedInput("outputValueForOntype", false),
27
+ },
28
+ outputValueForOff: {
29
+ value: false,
30
+ required: true,
31
+ validate: RED.validators.typedInput("outputValueForOfftype", false),
32
+ },
33
+ outputValueForOntype: {
34
+ value: "bool",
35
+ required: true,
36
+ },
37
+ outputValueForOfftype: {
38
+ value: "bool",
39
+ required: true,
40
+ },
41
+ },
42
+ inputs: 1,
43
+ outputs: 3,
44
+ icon: "font-awesome/fa-compress",
45
+ color: "#FFCC66",
46
+ label: function () {
47
+ return this.name || "Best Save";
48
+ },
49
+ outputLabels: ["on", "off", "schedule"],
50
+ oneditprepare: function () {
51
+ $("#node-input-outputIfNoSchedule").typedInput({
52
+ types: [
53
+ {
54
+ value: "onoff",
55
+ options: [
56
+ { value: "true", label: "On" },
57
+ { value: "false", label: "Off" },
58
+ ],
59
+ },
60
+ ],
61
+ });
62
+ $("#node-input-logicFunction").typedInput({
63
+ types: [
64
+ {
65
+ value: "logic",
66
+ options: ["OR", "AND"],
67
+ },
68
+ ],
69
+ });
70
+ $("#node-input-outputValueForOn").typedInput({
71
+ default: "bool",
72
+ typeField: $("#node-input-outputValueForOntype"),
73
+ types: ["bool", "num", "str"],
74
+ });
75
+ $("#node-input-outputValueForOff").typedInput({
76
+ default: "bool",
77
+ typeField: $("#node-input-outputValueForOfftype"),
78
+ types: ["bool", "num", "str"],
79
+ });
80
+ },
81
+ });
82
+ </script>
83
+
84
+ <script type="text/html" data-template-name="ps-schedule-merger">
85
+ <div class="form-row">
86
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
87
+ <input type="text" id="node-input-name" placeholder="Name" style="width: 240px">
88
+ </div>
89
+ <div class="form-row">
90
+ <label for="node-input-logicFunction">Function</label>
91
+ <input type="text" id="node-input-logicFunction" style="width: 80px">
92
+ </label>
93
+ </div>
94
+ <div class="form-row">
95
+ <label for="node-input-schedulingDelay"><i class="fa fa-clock-o"></i> Delay</label>
96
+ <input type="text" id="node-input-schedulingDelay" placeholder="milliseconds" style="width: 80px">
97
+ milliseconds
98
+ </div>
99
+ <div class="form-row">
100
+ <label for="node-input-outputValueForOn">Output value for on</label>
101
+ <input type="text" id="node-input-outputValueForOn" style="text-align: left; width: 120px">
102
+ <input type="hidden" id="node-input-outputValueForOntype">
103
+ </div>
104
+ <div class="form-row">
105
+ <label for="node-input-outputValueForOff">Output value for off</label>
106
+ <input type="text" id="node-input-outputValueForOff" style="text-align: left; width: 120px">
107
+ <input type="hidden" id="node-input-outputValueForOfftype">
108
+ </div>
109
+ </div>
110
+ <label for="node-input-sendCurrentValueWhenRescheduling" style="width:240px">
111
+ <input type="checkbox"
112
+ id="node-input-sendCurrentValueWhenRescheduling"
113
+ style="display:inline-block; width:22px; vertical-align:top;"
114
+ autocomplete="off"><span>Send when rescheduling</span>
115
+ </label>
116
+ </div>
117
+ <div class="form-row">
118
+ <label for="node-input-outputIfNoSchedule">If no schedule, send</label>
119
+ <input type="text" id="node-input-outputIfNoSchedule" style="width: 80px">
120
+ </label>
121
+ </div>
122
+ </script>
123
+
124
+ <script type="text/markdown" data-help-name="ps-schedule-merger">
125
+ A node you can use to merge multiple schedules to one.
126
+
127
+ Function OR will turn on if any of the input schedules are on.
128
+
129
+ Function AND will turn on only when all input schedules are on.
130
+
131
+ Delay is milliseconds before the merged schedule is sent. It is useful to wait for all schedules
132
+ to arrive before they are merged.
133
+
134
+ Please read more in the [node documentation](https://powersaver.no/nodes/schedule-merger)
135
+ </script>