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.
- package/.eslintrc.js +15 -0
- package/docs/.vuepress/components/DonateButtons.vue +26 -3
- package/docs/.vuepress/components/VippsPlakat.vue +20 -0
- package/docs/.vuepress/config.js +17 -10
- package/docs/.vuepress/public/ads.txt +1 -0
- package/docs/README.md +4 -4
- package/docs/changelog/README.md +55 -1
- package/docs/contribute/README.md +8 -3
- package/docs/examples/example-grid-tariff-capacity-flow.json +23 -7
- package/docs/examples/example-grid-tariff-capacity-part.md +657 -22
- package/docs/faq/README.md +1 -1
- package/docs/faq/best-save-viewer.md +1 -1
- package/docs/guide/README.md +20 -5
- package/docs/images/best-save-config.png +0 -0
- package/docs/images/combine-two-lowest-price.png +0 -0
- package/docs/images/fixed-schedule-config.png +0 -0
- package/docs/images/global-context-window.png +0 -0
- package/docs/images/lowest-price-config.png +0 -0
- package/docs/images/node-ps-schedule-merger.png +0 -0
- package/docs/images/node-ps-strategy-fixed-schedule.png +0 -0
- package/docs/images/ps-strategy-fixed-schedule-example.png +0 -0
- package/docs/images/schedule-merger-config.png +0 -0
- package/docs/images/schedule-merger-example-1.png +0 -0
- package/docs/images/vipps-plakat.png +0 -0
- package/docs/images/vipps-qr.png +0 -0
- package/docs/images/vipps-smiling-rgb-orange-pos.png +0 -0
- package/docs/nodes/README.md +12 -6
- package/docs/nodes/dynamic-commands.md +79 -0
- package/docs/nodes/dynamic-config.md +76 -0
- package/docs/nodes/ps-elvia-add-tariff.md +4 -0
- package/docs/nodes/ps-general-add-tariff.md +10 -0
- package/docs/nodes/ps-receive-price.md +2 -1
- package/docs/nodes/ps-schedule-merger.md +227 -0
- package/docs/nodes/ps-strategy-best-save.md +46 -110
- package/docs/nodes/ps-strategy-fixed-schedule.md +101 -0
- package/docs/nodes/ps-strategy-heat-capacitor.md +6 -1
- package/docs/nodes/ps-strategy-lowest-price.md +51 -112
- package/package.json +5 -2
- package/src/elvia/elvia-add-tariff.html +1 -2
- package/src/elvia/elvia-add-tariff.js +0 -1
- package/src/elvia/elvia-api.js +6 -0
- package/src/elvia/elvia-tariff.html +1 -1
- package/src/general-add-tariff.html +14 -8
- package/src/general-add-tariff.js +0 -1
- package/src/handle-input.js +94 -106
- package/src/handle-output.js +109 -0
- package/src/receive-price-functions.js +3 -3
- package/src/schedule-merger-functions.js +98 -0
- package/src/schedule-merger.html +135 -0
- package/src/schedule-merger.js +108 -0
- package/src/strategy-best-save.html +38 -1
- package/src/strategy-best-save.js +17 -63
- package/src/strategy-fixed-schedule.html +339 -0
- package/src/strategy-fixed-schedule.js +84 -0
- package/src/strategy-functions.js +35 -0
- package/src/strategy-lowest-price.html +76 -38
- package/src/strategy-lowest-price.js +16 -35
- package/src/utils.js +75 -2
- package/test/commands-input-best-save.test.js +142 -0
- package/test/commands-input-lowest-price.test.js +149 -0
- package/test/commands-input-schedule-merger.test.js +128 -0
- package/test/data/best-save-overlap-result.json +5 -1
- package/test/data/best-save-result.json +4 -0
- package/test/data/commands-result-best-save.json +383 -0
- package/test/data/commands-result-lowest-price.json +340 -0
- package/test/data/fixed-schedule-result.json +353 -0
- package/test/data/lowest-price-result-cont-max-fail.json +5 -1
- package/test/data/lowest-price-result-cont-max.json +3 -1
- package/test/data/lowest-price-result-cont.json +8 -1
- package/test/data/lowest-price-result-missing-end.json +8 -3
- package/test/data/lowest-price-result-neg-cont.json +27 -0
- package/test/data/lowest-price-result-neg-split.json +23 -0
- package/test/data/lowest-price-result-split-allday.json +3 -1
- package/test/data/lowest-price-result-split-allday10.json +1 -0
- package/test/data/lowest-price-result-split-max.json +3 -1
- package/test/data/lowest-price-result-split.json +3 -1
- package/test/data/merge-schedule-data.js +238 -0
- package/test/data/negative-prices.json +197 -0
- package/test/data/nordpool-event-prices.json +96 -480
- package/test/data/nordpool-zero-prices.json +90 -0
- package/test/data/reconfigResult.js +1 -0
- package/test/data/result.js +1 -0
- package/test/data/tibber-result-end-0-24h.json +12 -2
- package/test/data/tibber-result-end-0.json +12 -2
- package/test/data/tibber-result.json +1 -0
- package/test/receive-price.test.js +22 -0
- package/test/schedule-merger-functions.test.js +101 -0
- package/test/schedule-merger-test-utils.js +27 -0
- package/test/schedule-merger.test.js +130 -0
- package/test/send-config-input.test.js +45 -2
- package/test/strategy-best-save-test-utils.js +1 -1
- package/test/strategy-best-save.test.js +45 -0
- package/test/strategy-fixed-schedule.test.js +117 -0
- package/test/strategy-heat-capacitor.test.js +1 -1
- package/test/strategy-lowest-price-functions.test.js +1 -1
- package/test/strategy-lowest-price-test-utils.js +31 -0
- package/test/strategy-lowest-price.test.js +55 -45
- package/test/test-utils.js +43 -36
- package/test/utils.test.js +13 -0
- package/docs/images/node-power-saver.png +0 -0
- package/docs/nodes/power-saver.md +0 -23
- package/src/power-saver.html +0 -116
- package/src/power-saver.js +0 -260
- package/test/commands-input.test.js +0 -47
- package/test/power-saver.test.js +0 -189
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
<script type="text/javascript">
|
|
2
|
-
const
|
|
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) =>
|
|
14
|
+
return !this.periods.some((p) => !/^(\d+\.\d*)|(\d+)$/.test("" + p.value));
|
|
17
15
|
},
|
|
18
16
|
},
|
|
19
|
-
validFrom: {
|
|
20
|
-
|
|
21
|
-
|
|
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 = { ...
|
|
196
|
+
this.days = { ...defaultDaysGat };
|
|
191
197
|
}
|
|
192
198
|
drawDays(this.days);
|
|
193
199
|
},
|
package/src/handle-input.js
CHANGED
|
@@ -1,22 +1,14 @@
|
|
|
1
|
-
const { extractPlanForDate,
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 ||
|
|
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>
|