node-red-contrib-power-saver 2.0.4 → 3.0.1
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/CHANGELOG.md +1 -33
- package/README.md +3 -191
- package/docs/.vuepress/config.js +67 -0
- package/docs/.vuepress/dist/404.html +15 -0
- package/docs/.vuepress/dist/assets/css/styles.e835bef6.css +8 -0
- package/docs/.vuepress/dist/assets/img/back-to-top.8b37f773.svg +1 -0
- package/docs/.vuepress/dist/assets/img/elvia-config-no-config.b4bb972c.png +0 -0
- package/docs/.vuepress/dist/assets/img/elvia-config-no-tariff.3f89aba8.png +0 -0
- package/docs/.vuepress/dist/assets/img/elvia-config-select-tariff.0f73fd56.png +0 -0
- package/docs/.vuepress/dist/assets/img/elvia-config-subscription-key.8be8ab8a.png +0 -0
- package/docs/.vuepress/dist/assets/img/elvia-flow.bae2a4d5.png +0 -0
- package/docs/.vuepress/dist/assets/img/example-flow-1.3ff3e23f.png +0 -0
- package/docs/.vuepress/dist/assets/img/example-flow-2.b653b58d.png +0 -0
- package/docs/.vuepress/dist/assets/img/migrate-best-save.f73420f6.png +0 -0
- package/docs/.vuepress/dist/assets/img/migrate-power-saver.aae13f9d.png +0 -0
- package/docs/.vuepress/dist/assets/img/node-power-saver.51ff2e5d.png +0 -0
- package/docs/.vuepress/dist/assets/img/node-ps-elvia-add-tariff.94ea2b09.png +0 -0
- package/docs/.vuepress/dist/assets/img/node-ps-receive-price.76eaa418.png +0 -0
- package/docs/.vuepress/dist/assets/img/node-ps-strategy-best-save.392292d5.png +0 -0
- package/docs/.vuepress/dist/assets/img/node-ps-strategy-lowest-price.3a4ad347.png +0 -0
- package/docs/.vuepress/dist/assets/img/power-saver-nordpool-current-state.bf14afde.png +0 -0
- package/docs/.vuepress/dist/assets/img/power-saver-nordpool-events-state.8c392507.png +0 -0
- package/docs/.vuepress/dist/assets/img/power-saver-tibber-mqtt.16891dd2.png +0 -0
- package/docs/.vuepress/dist/assets/js/293.5e967839.js +1 -0
- package/docs/.vuepress/dist/assets/js/491.c183eba3.js +1 -0
- package/docs/.vuepress/dist/assets/js/812.79dad458.js +2 -0
- package/docs/.vuepress/dist/assets/js/812.79dad458.js.LICENSE.txt +8 -0
- package/docs/.vuepress/dist/assets/js/app.80d4373d.js +1 -0
- package/docs/.vuepress/dist/assets/js/runtime~app.665b411c.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-08683c60.9edcaa60.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-0aca7ba6.5eca5160.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-0b5e3c8c.c02472fb.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-1ad821fa.a628e907.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-30acb564.80b4190d.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-3706649a.d7f73384.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-4637f9e4.df94c6ea.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-510ed0d4.a78d8542.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-5954bcb2.4f4712f3.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-5db8da3a.2e49b81a.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-61f728ca.7b545524.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-677dfaed.756e0fb5.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-7c87f26e.57507077.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-8daa1a0e.1ea39527.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-b4a42144.a812c440.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-e8c55052.30f30acd.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-fffb8e28.850019c1.js +1 -0
- package/docs/.vuepress/dist/changelog/index.html +15 -0
- package/docs/.vuepress/dist/contribute/index.html +15 -0
- package/docs/.vuepress/dist/euro.png +0 -0
- package/docs/.vuepress/dist/examples/example-nordpool-current-state.html +169 -0
- package/docs/.vuepress/dist/examples/example-nordpool-events-state.html +173 -0
- package/docs/.vuepress/dist/examples/example-tibber-mqtt.html +182 -0
- package/docs/.vuepress/dist/examples/index.html +15 -0
- package/docs/.vuepress/dist/guide/index.html +52 -0
- package/docs/.vuepress/dist/index.html +15 -0
- package/docs/.vuepress/dist/logo.png +0 -0
- package/docs/.vuepress/dist/nodes/index.html +15 -0
- package/docs/.vuepress/dist/nodes/old-power-saver-doc.html +97 -0
- package/docs/.vuepress/dist/nodes/power-saver.html +15 -0
- package/docs/.vuepress/dist/nodes/ps-elvia-add-tariff.html +15 -0
- package/docs/.vuepress/dist/nodes/ps-receive-price.html +80 -0
- package/docs/.vuepress/dist/nodes/ps-strategy-best-save.html +65 -0
- package/docs/.vuepress/dist/nodes/ps-strategy-lowest-price.html +89 -0
- package/docs/.vuepress/dist/nodes/strategy-input.html +40 -0
- package/docs/.vuepress/public/euro.png +0 -0
- package/docs/.vuepress/public/logo.png +0 -0
- package/docs/README.md +32 -0
- package/docs/changelog/README.md +61 -0
- package/docs/contribute/README.md +39 -0
- package/docs/examples/README.md +5 -0
- package/docs/examples/example-nordpool-current-state.md +166 -0
- package/docs/examples/example-nordpool-events-state.md +170 -0
- package/docs/examples/example-tibber-mqtt.md +179 -0
- package/docs/guide/README.md +202 -0
- package/docs/images/all-nodes.png +0 -0
- package/docs/images/best-save-config.png +0 -0
- package/docs/images/elvia-add-tariff-node-used.png +0 -0
- package/docs/images/elvia-config-no-config.png +0 -0
- package/docs/images/elvia-config-no-tariff.png +0 -0
- package/docs/images/elvia-config-select-tariff.png +0 -0
- package/docs/images/elvia-config-subscription-key.png +0 -0
- package/docs/images/elvia-flow.png +0 -0
- package/docs/images/elvia-tariff-config.png +0 -0
- package/docs/images/euro.png +0 -0
- package/docs/images/example-flow-1.png +0 -0
- package/docs/images/example-flow-2.png +0 -0
- package/docs/images/logo.png +0 -0
- package/docs/images/lowest-price-config.png +0 -0
- package/docs/images/migrate-best-save.png +0 -0
- package/docs/images/migrate-power-saver.png +0 -0
- package/docs/images/node-power-saver.png +0 -0
- package/docs/images/node-ps-elvia-add-tariff.png +0 -0
- package/docs/images/node-ps-elvia-tariff-types.png +0 -0
- package/docs/images/node-ps-elvia-tariff.png +0 -0
- package/docs/images/node-ps-receive-price.png +0 -0
- package/docs/images/node-ps-strategy-best-save.png +0 -0
- package/docs/images/node-ps-strategy-lowest-price.png +0 -0
- package/{doc → docs/images}/node-red-contrib-power-saver-flow.png +0 -0
- package/docs/images/power-saver-nordpool-current-state.png +0 -0
- package/docs/images/power-saver-nordpool-events-state.png +0 -0
- package/docs/images/power-saver-tibber-mqtt.png +0 -0
- package/docs/nodes/README.md +53 -0
- package/docs/nodes/old-power-saver-doc.md +231 -0
- package/docs/nodes/power-saver.md +23 -0
- package/docs/nodes/ps-elvia-add-tariff.md +52 -0
- package/docs/nodes/ps-receive-price.md +153 -0
- package/docs/nodes/ps-strategy-best-save.md +142 -0
- package/docs/nodes/ps-strategy-lowest-price.md +165 -0
- package/docs/nodes/strategy-input.md +39 -0
- package/package.json +19 -4
- package/src/elvia/elvia-add-tariff.html +70 -0
- package/src/elvia/elvia-add-tariff.js +47 -0
- package/src/elvia/elvia-api.js +61 -0
- package/src/elvia/elvia-config.html +46 -0
- package/src/elvia/elvia-config.js +19 -0
- package/src/elvia/elvia-tariff-types.html +34 -0
- package/src/elvia/elvia-tariff-types.js +25 -0
- package/src/elvia/elvia-tariff.html +89 -0
- package/src/elvia/elvia-tariff.js +22 -0
- package/src/elvia/icons/elvia_hvite.svg +4 -0
- package/src/elvia/icons/elvia_positive_4 copy.svg +4 -0
- package/src/handle-input.js +162 -0
- package/src/power-saver.html +116 -0
- package/{power-saver.js → src/power-saver.js} +90 -72
- package/src/receive-price-functions.js +99 -0
- package/src/receive-price.html +30 -0
- package/src/receive-price.js +21 -0
- package/src/strategy-best-save-functions.js +110 -0
- package/src/strategy-best-save.html +116 -0
- package/src/strategy-best-save.js +95 -0
- package/src/strategy-lowest-price-functions.js +35 -0
- package/src/strategy-lowest-price.html +168 -0
- package/src/strategy-lowest-price.js +125 -0
- package/{utils.js → src/utils.js} +59 -104
- package/test/data/adjustedResult.js +302 -0
- package/test/data/adjustedResult_old.js +154 -0
- package/test/data/best-save-result.json +357 -0
- package/test/data/converted-prices.json +196 -0
- package/test/data/elvia-input-grid-tariff.json +760 -0
- package/test/data/elvia-input-power-prices.json +194 -0
- package/test/data/elvia-output-add-tariff.json +290 -0
- package/test/data/lowest-price-result-cont.json +18 -0
- package/test/data/lowest-price-result-split-allday.json +21 -0
- package/test/data/lowest-price-result-split-allday10.json +20 -0
- package/test/data/lowest-price-result-split.json +20 -0
- package/test/data/nordpool-current-state-prices.json +283 -0
- package/test/data/nordpool-event-prices.json +574 -0
- package/test/data/reconfigResult.js +315 -0
- package/test/data/reconfigResult_old.js +141 -0
- package/test/data/result.js +1 -0
- package/test/data/tibber-prices-single-home.json +64 -0
- package/test/data/tibber-prices.json +124 -0
- package/test/data/{tibber_result.json → tibber-result.json} +2 -1
- package/test/elvia.test.js +26 -0
- package/test/mostSavedStrategy.test.js +22 -55
- package/test/power-saver.test.js +4 -38
- package/test/receive-price-functions.test.js +153 -0
- package/test/receive-price.test.js +122 -0
- package/test/send-config-input.test.js +121 -0
- package/test/strategy-best-save-test-utils.js +32 -0
- package/test/strategy-best-save.test.js +103 -0
- package/test/strategy-lowest-price-functions.test.js +40 -0
- package/test/strategy-lowest-price.test.js +472 -0
- package/test/test-utils.js +106 -0
- package/test/utils.test.js +53 -163
- package/doc/example-nordpool-current-state.md +0 -166
- package/doc/example-nordpool-events-state.md +0 -153
- package/doc/example-tibber-mqtt.md +0 -189
- package/doc/power-saver-nordpool-current-state.png +0 -0
- package/doc/power-saver-nordpool-events-state.png +0 -0
- package/doc/power-saver-tibber-mqtt.png +0 -0
- package/mostSavedStrategy.js +0 -84
- package/mostSavedStrategy_v2.js +0 -68
- package/power-saver.html +0 -259
- package/test/data/tibber_data.json +0 -412
- package/test/data/tibber_prices.json +0 -412
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
const { DateTime } = require("luxon");
|
|
2
2
|
const {
|
|
3
|
-
convertMsg,
|
|
4
3
|
countAtEnd,
|
|
5
|
-
makeSchedule,
|
|
6
|
-
getSavings,
|
|
7
4
|
extractPlanForDate,
|
|
5
|
+
getDiff,
|
|
6
|
+
getEffectiveConfig,
|
|
7
|
+
getSavings,
|
|
8
|
+
getStartAtIndex,
|
|
9
|
+
loadDayData,
|
|
10
|
+
makeSchedule,
|
|
8
11
|
} = require("./utils");
|
|
9
|
-
const
|
|
12
|
+
const { convertMsg } = require("./receive-price-functions");
|
|
13
|
+
const { calculate } = require("./strategy-best-save-functions");
|
|
10
14
|
|
|
11
15
|
let schedulingTimeout = null;
|
|
12
16
|
|
|
@@ -15,57 +19,57 @@ module.exports = function (RED) {
|
|
|
15
19
|
RED.nodes.createNode(this, config);
|
|
16
20
|
const node = this;
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
config.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
config.
|
|
25
|
-
|
|
22
|
+
const originalConfig = {
|
|
23
|
+
maxHoursToSaveInSequence: config.maxHoursToSaveInSequence,
|
|
24
|
+
minHoursOnAfterMaxSequenceSaved: config.minHoursOnAfterMaxSequenceSaved,
|
|
25
|
+
minSaving: parseFloat(config.minSaving),
|
|
26
|
+
sendCurrentValueWhenRescheduling: config.sendCurrentValueWhenRescheduling,
|
|
27
|
+
outputIfNoSchedule: config.outputIfNoSchedule === "true",
|
|
28
|
+
scheduleOnlyFromCurrentTime: config.scheduleOnlyFromCurrentTime === "true",
|
|
29
|
+
};
|
|
30
|
+
node.context().set("config", originalConfig);
|
|
26
31
|
|
|
27
32
|
node.on("close", function () {
|
|
28
33
|
clearTimeout(schedulingTimeout);
|
|
29
34
|
});
|
|
30
35
|
|
|
31
36
|
node.on("input", function (msg) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const input = convertMsg(msg);
|
|
37
|
-
if (!validateInput(node, input)) {
|
|
37
|
+
const effectiveConfig = getEffectiveConfig(node, msg);
|
|
38
|
+
const priceData = getPriceData(node, msg);
|
|
39
|
+
if (!priceData) {
|
|
38
40
|
return;
|
|
39
41
|
}
|
|
42
|
+
const planFromTime = msg.payload.time ? DateTime.fromISO(msg.payload.time) : DateTime.now();
|
|
40
43
|
|
|
41
|
-
|
|
44
|
+
// Store config variables in node
|
|
45
|
+
Object.keys(effectiveConfig).forEach((key) => (node[key] = effectiveConfig[key]));
|
|
42
46
|
|
|
43
47
|
clearTimeout(schedulingTimeout);
|
|
44
48
|
|
|
45
|
-
const dates = [
|
|
46
|
-
...new Set(priceData.map((v) => DateTime.fromISO(v.start).toISODate())),
|
|
47
|
-
];
|
|
49
|
+
const dates = [...new Set(priceData.map((v) => DateTime.fromISO(v.start).toISODate()))];
|
|
48
50
|
|
|
49
51
|
// Load data from day before
|
|
52
|
+
const dateToday = DateTime.fromISO(dates[0]);
|
|
50
53
|
const dateDayBefore = DateTime.fromISO(dates[0]).plus({ days: -1 });
|
|
51
|
-
const dataDayBefore = loadDayData(node, dateDayBefore);
|
|
52
54
|
|
|
53
55
|
// Make plan
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
56
|
+
const startAtIndex = getStartAtIndex(effectiveConfig, priceData, planFromTime);
|
|
57
|
+
const dataJustBefore = loadDataJustBefore(node, dateDayBefore, dateToday, startAtIndex);
|
|
58
|
+
const values = priceData.map((d) => d.value).slice(startAtIndex);
|
|
59
|
+
const startTimes = priceData.map((d) => d.start).slice(startAtIndex);
|
|
60
|
+
const onOffBefore = dataJustBefore.hours.map((h) => h.onOff);
|
|
61
|
+
const lastPlanHours = node.context().get("lastPlan")?.hours ?? [];
|
|
62
|
+
const plan = makePlan(node, values, startTimes, onOffBefore);
|
|
63
|
+
const includeFromLastPlanHours = lastPlanHours.filter(
|
|
64
|
+
(h) => h.start < plan.hours[0].start && h.start >= priceData[0].start
|
|
65
|
+
);
|
|
66
|
+
adjustSavingsPassedHours(plan, includeFromLastPlanHours);
|
|
67
|
+
plan.hours.splice(0, 0, ...includeFromLastPlanHours);
|
|
57
68
|
|
|
58
69
|
// Save schedule
|
|
70
|
+
node.context().set("lastPlan", plan);
|
|
59
71
|
dates.forEach((d) => saveDayData(node, d, extractPlanForDate(plan, d)));
|
|
60
72
|
|
|
61
|
-
const config = {
|
|
62
|
-
maxHoursToSaveInSequence: this.maxHoursToSaveInSequence,
|
|
63
|
-
minHoursOnAfterMaxSequenceSaved: this.minHoursOnAfterMaxSequenceSaved,
|
|
64
|
-
minSaving: this.minSaving,
|
|
65
|
-
sendCurrentValueWhenRescheduling: this.sendCurrentValueWhenRescheduling,
|
|
66
|
-
outputIfNoSchedule: this.outputIfNoSchedule,
|
|
67
|
-
};
|
|
68
|
-
|
|
69
73
|
// Prepare output
|
|
70
74
|
let output1 = null;
|
|
71
75
|
let output2 = null;
|
|
@@ -73,21 +77,15 @@ module.exports = function (RED) {
|
|
|
73
77
|
payload: {
|
|
74
78
|
schedule: plan.schedule,
|
|
75
79
|
hours: plan.hours,
|
|
76
|
-
source:
|
|
77
|
-
config,
|
|
80
|
+
source: priceData.source,
|
|
81
|
+
config: effectiveConfig,
|
|
78
82
|
},
|
|
79
83
|
};
|
|
80
84
|
|
|
81
85
|
// Find current output, and set output (if configured to do)
|
|
82
|
-
const
|
|
83
|
-
? DateTime.fromISO(msg.payload.time)
|
|
84
|
-
: DateTime.now();
|
|
85
|
-
const pastSchedule = plan.schedule.filter(
|
|
86
|
-
(entry) => DateTime.fromISO(entry.time) <= time
|
|
87
|
-
);
|
|
88
|
-
const outputCurrent = node.sendCurrentValueWhenRescheduling;
|
|
86
|
+
const pastSchedule = plan.schedule.filter((entry) => DateTime.fromISO(entry.time) <= planFromTime);
|
|
89
87
|
|
|
90
|
-
if (
|
|
88
|
+
if (node.sendCurrentValueWhenRescheduling && pastSchedule.length > 0) {
|
|
91
89
|
const currentValue = pastSchedule[pastSchedule.length - 1].value;
|
|
92
90
|
output1 = currentValue ? { payload: true } : null;
|
|
93
91
|
output2 = currentValue ? null : { payload: false };
|
|
@@ -100,26 +98,53 @@ module.exports = function (RED) {
|
|
|
100
98
|
node.send([output1, output2, output3]);
|
|
101
99
|
|
|
102
100
|
// Run schedule
|
|
103
|
-
schedulingTimeout = runSchedule(node, plan.schedule,
|
|
101
|
+
schedulingTimeout = runSchedule(node, plan.schedule, planFromTime);
|
|
104
102
|
});
|
|
105
103
|
}
|
|
106
104
|
|
|
107
105
|
RED.nodes.registerType("power-saver", PowerSaverNode);
|
|
108
106
|
};
|
|
109
107
|
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
108
|
+
function adjustSavingsPassedHours(plan, includeFromLastPlanHours) {
|
|
109
|
+
const firstOnIndex = plan.hours.findIndex((h) => h.onOff);
|
|
110
|
+
if (firstOnIndex < 0) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const nextOnValue = plan.hours[firstOnIndex].price;
|
|
114
|
+
let adjustIndex = includeFromLastPlanHours.length - 1;
|
|
115
|
+
while (adjustIndex >= 0 && !includeFromLastPlanHours[adjustIndex].onOff) {
|
|
116
|
+
includeFromLastPlanHours[adjustIndex].saving = getDiff(includeFromLastPlanHours[adjustIndex].price, nextOnValue);
|
|
117
|
+
adjustIndex--;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getPriceData(node, msg) {
|
|
122
|
+
const isConfigMsg = !!msg?.payload?.config;
|
|
123
|
+
if (isConfigMsg) {
|
|
124
|
+
return node.context().get("lastPriceData");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!validateMsg(node, msg)) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const input = convertMsg(msg);
|
|
131
|
+
if (!validateInput(node, input)) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
priceData = [...input.today, ...input.tomorrow];
|
|
136
|
+
priceData.source = input.source;
|
|
137
|
+
node.context().set("lastPriceData", priceData);
|
|
138
|
+
return priceData;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function loadDataJustBefore(node, dateDayBefore, dateToday, startAtIndex) {
|
|
142
|
+
const dataDayBefore = loadDayData(node, dateDayBefore);
|
|
143
|
+
const dataToday = loadDayData(node, dateToday);
|
|
144
|
+
return {
|
|
145
|
+
schedule: [...dataDayBefore.schedule, ...dataToday.schedule.slice(0, startAtIndex)],
|
|
146
|
+
hours: [...dataDayBefore.hours, ...dataToday.hours.slice(0, startAtIndex)],
|
|
147
|
+
};
|
|
123
148
|
}
|
|
124
149
|
|
|
125
150
|
function saveDayData(node, date, plan) {
|
|
@@ -140,7 +165,7 @@ function makePlan(node, values, startTimes, onOffBefore, firstValueNextDay) {
|
|
|
140
165
|
const lastCountDayBefore = countAtEnd(onOffBefore, lastValueDayBefore);
|
|
141
166
|
const onOff =
|
|
142
167
|
strategy === "mostSaved"
|
|
143
|
-
?
|
|
168
|
+
? calculate(
|
|
144
169
|
values,
|
|
145
170
|
node.maxHoursToSaveInSequence,
|
|
146
171
|
node.minHoursOnAfterMaxSequenceSaved,
|
|
@@ -161,7 +186,6 @@ function makePlan(node, values, startTimes, onOffBefore, firstValueNextDay) {
|
|
|
161
186
|
return {
|
|
162
187
|
hours,
|
|
163
188
|
schedule,
|
|
164
|
-
onOff,
|
|
165
189
|
};
|
|
166
190
|
}
|
|
167
191
|
|
|
@@ -175,8 +199,7 @@ function validateMsg(node, msg) {
|
|
|
175
199
|
validationFailure(node, "Payload missing");
|
|
176
200
|
return false;
|
|
177
201
|
}
|
|
178
|
-
const payload =
|
|
179
|
-
msg.data?.new_state?.attributes ?? msg.data?.attributes ?? msg.payload;
|
|
202
|
+
const payload = msg.data?.new_state?.attributes ?? msg.data?.attributes ?? msg.payload;
|
|
180
203
|
if (typeof payload !== "object") {
|
|
181
204
|
validationFailure(node, "Payload must be an object");
|
|
182
205
|
return false;
|
|
@@ -191,10 +214,7 @@ function validateInput(node, input) {
|
|
|
191
214
|
return day.start === undefined || day.value === undefined;
|
|
192
215
|
})
|
|
193
216
|
) {
|
|
194
|
-
validationFailure(
|
|
195
|
-
node,
|
|
196
|
-
`Malformed entries in payload.${arr}. All entries must contain start and value.`
|
|
197
|
-
);
|
|
217
|
+
validationFailure(node, `Malformed entries in payload.${arr}. All entries must contain start and value.`);
|
|
198
218
|
}
|
|
199
219
|
});
|
|
200
220
|
if (!input.today.length && !input.tomorrow.length) {
|
|
@@ -214,18 +234,16 @@ function validateInput(node, input) {
|
|
|
214
234
|
*/
|
|
215
235
|
function runSchedule(node, schedule, time) {
|
|
216
236
|
let currentTime = time;
|
|
217
|
-
let remainingSchedule = schedule.filter(
|
|
218
|
-
(entry) => DateTime.fromISO(entry.time) > time
|
|
219
|
-
);
|
|
237
|
+
let remainingSchedule = schedule.filter((entry) => DateTime.fromISO(entry.time) > time);
|
|
220
238
|
if (remainingSchedule.length > 0) {
|
|
221
239
|
const entry = remainingSchedule[0];
|
|
222
240
|
const nextTime = DateTime.fromISO(entry.time);
|
|
223
241
|
const wait = nextTime - currentTime;
|
|
224
242
|
const onOff = entry.value ? "on" : "off";
|
|
225
243
|
node.log("Switching " + onOff + " in " + wait + " milliseconds");
|
|
226
|
-
const statusMessage = `Scheduled ${
|
|
227
|
-
remainingSchedule.
|
|
228
|
-
}
|
|
244
|
+
const statusMessage = `Scheduled ${remainingSchedule.length} changes. Next: ${
|
|
245
|
+
remainingSchedule[0].value ? "on" : "off"
|
|
246
|
+
}`;
|
|
229
247
|
node.status({ fill: "green", shape: "dot", text: statusMessage });
|
|
230
248
|
return setTimeout(() => {
|
|
231
249
|
sendSwitch(node, entry.value);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const { validationFailure } = require("./utils");
|
|
2
|
+
|
|
3
|
+
function getPriceData(node, msg) {
|
|
4
|
+
const isConfigMsg = !!msg?.payload?.config;
|
|
5
|
+
if (isConfigMsg) {
|
|
6
|
+
return node.context().get("lastPriceData");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (!validateMsg(node, msg)) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const input = convertMsg(msg);
|
|
13
|
+
if (!validateInput(node, input)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
priceData = [...input.today, ...input.tomorrow];
|
|
18
|
+
priceData.source = input.source;
|
|
19
|
+
node.context().set("lastPriceData", priceData);
|
|
20
|
+
return priceData;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validateMsg(node, msg) {
|
|
24
|
+
if (!msg.payload && !msg.data?.new_state?.attributes) {
|
|
25
|
+
validationFailure(node, "Payload missing");
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const payload = msg.data?.new_state?.attributes ?? msg.data?.attributes ?? msg.payload;
|
|
29
|
+
if (typeof payload !== "object") {
|
|
30
|
+
validationFailure(node, "Payload must be an object");
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validateInput(node, input) {
|
|
37
|
+
["today", "tomorrow"].forEach((arr) => {
|
|
38
|
+
if (
|
|
39
|
+
input[arr].some((day) => {
|
|
40
|
+
return day.start === undefined || day.value === undefined;
|
|
41
|
+
})
|
|
42
|
+
) {
|
|
43
|
+
validationFailure(node, `Malformed entries in payload.${arr}. All entries must contain start and value.`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
if (!input.today.length && !input.tomorrow.length) {
|
|
47
|
+
validationFailure(node, "Payload has no data");
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get today and tomorrow data out of the input message.
|
|
56
|
+
* Can accept 3 types of messages: Tibber, Nordpool or plain payload with data already converted.
|
|
57
|
+
* @param {*} msg
|
|
58
|
+
*/
|
|
59
|
+
function convertMsg(msg) {
|
|
60
|
+
const result = { source: "Unknown" };
|
|
61
|
+
|
|
62
|
+
["today", "tomorrow"].forEach((day) => {
|
|
63
|
+
if (msg.payload?.viewer?.home?.currentSubscription?.priceInfo[day]) {
|
|
64
|
+
result.source = "Tibber";
|
|
65
|
+
result[day] = msg.payload.viewer.home.currentSubscription.priceInfo[day].map((v) => ({
|
|
66
|
+
value: v.total,
|
|
67
|
+
start: v.startsAt,
|
|
68
|
+
}));
|
|
69
|
+
} else if (msg.payload?.viewer?.homes && msg.payload?.viewer?.homes[0]?.currentSubscription?.priceInfo[day]) {
|
|
70
|
+
result.source = "Tibber";
|
|
71
|
+
result[day] = msg.payload.viewer.homes[0].currentSubscription.priceInfo[day].map((v) => ({
|
|
72
|
+
value: v.total,
|
|
73
|
+
start: v.startsAt,
|
|
74
|
+
}));
|
|
75
|
+
} else if (msg.data?.new_state?.attributes["raw_" + day]) {
|
|
76
|
+
result.source = "Nordpool";
|
|
77
|
+
result[day] = msg.data.new_state.attributes["raw_" + day].map((v) => ({
|
|
78
|
+
value: v.value,
|
|
79
|
+
start: v.start,
|
|
80
|
+
}));
|
|
81
|
+
} else if (msg.payload?.attributes && msg.payload?.attributes["raw_" + day]) {
|
|
82
|
+
result.source = "Nordpool";
|
|
83
|
+
result[day] = msg.payload.attributes["raw_" + day].map((v) => ({
|
|
84
|
+
value: v.value,
|
|
85
|
+
start: v.start,
|
|
86
|
+
}));
|
|
87
|
+
} else {
|
|
88
|
+
result.source = "Other";
|
|
89
|
+
result[day] = msg.payload[day] || [];
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
getPriceData,
|
|
98
|
+
convertMsg,
|
|
99
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType("ps-receive-price", {
|
|
3
|
+
category: "Power Saver",
|
|
4
|
+
color: "#a6bbcf",
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: "Price Receiver" },
|
|
7
|
+
},
|
|
8
|
+
inputs: 1,
|
|
9
|
+
outputs: 1,
|
|
10
|
+
icon: "font-awesome/fa-euro",
|
|
11
|
+
color: "#FFCC66",
|
|
12
|
+
label: function () {
|
|
13
|
+
return this.name || "Price Receiver";
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<script type="text/html" data-template-name="ps-receive-price">
|
|
19
|
+
<div class="form-row">
|
|
20
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
21
|
+
<input type="text" id="node-input-name" placeholder="Name" style="width: 240px" />
|
|
22
|
+
</div>
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<script type="text/markdown" data-help-name="ps-receive-price">
|
|
26
|
+
A node to receive price data from one of the supported sources,
|
|
27
|
+
and adapt it for calculation in the Power Saver strategy nodes.
|
|
28
|
+
|
|
29
|
+
Please read more in the [node documentation](https://ottopaulsen.github.io/node-red-contrib-power-saver/nodes/ps-receive-price)
|
|
30
|
+
</script>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const { getPriceData } = require("./receive-price-functions");
|
|
2
|
+
|
|
3
|
+
module.exports = function (RED) {
|
|
4
|
+
function ReceivePriceNode(config) {
|
|
5
|
+
RED.nodes.createNode(this, config);
|
|
6
|
+
const node = this;
|
|
7
|
+
|
|
8
|
+
node.on("input", function (msg) {
|
|
9
|
+
const priceData = getPriceData(node, msg);
|
|
10
|
+
if (!priceData) {
|
|
11
|
+
// Set status failed
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Send output
|
|
16
|
+
node.send({ payload: { priceData } });
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
RED.nodes.registerType("ps-receive-price", ReceivePriceNode);
|
|
21
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { fillArray } = require("./utils");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Takes an array of true/false values where true means on and false means off.
|
|
7
|
+
* Evaluates of the on/off sequences are valid according to other arguments.
|
|
8
|
+
*
|
|
9
|
+
* @param {*} onOff Array of on/off values
|
|
10
|
+
* @param {*} maxOff Max number of values that can be off in a sequence
|
|
11
|
+
* @param {*} minOnAfterOff Min number of values that must be on after maxOff is reached
|
|
12
|
+
* @returns
|
|
13
|
+
*/
|
|
14
|
+
function isOnOffSequencesOk(onOff, maxOff, minOnAfterOff) {
|
|
15
|
+
let offCount = 0;
|
|
16
|
+
let onCount = 0;
|
|
17
|
+
let reachedMaxOff = false;
|
|
18
|
+
for (let i = 0; i < onOff.length; i++) {
|
|
19
|
+
if (!onOff[i]) {
|
|
20
|
+
if (maxOff === 0 || reachedMaxOff) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
offCount++;
|
|
24
|
+
onCount = 0;
|
|
25
|
+
if (offCount >= maxOff) {
|
|
26
|
+
reachedMaxOff = true;
|
|
27
|
+
}
|
|
28
|
+
} else {
|
|
29
|
+
if (reachedMaxOff) {
|
|
30
|
+
onCount++;
|
|
31
|
+
if (onCount >= minOnAfterOff) {
|
|
32
|
+
reachedMaxOff = false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
offCount = 0;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Turn off the hours where you save most compared to the next hour on.
|
|
43
|
+
*
|
|
44
|
+
* @param {*} values Array of prices
|
|
45
|
+
* @param {*} maxOffInARow Max number of hours that can be saved in a row
|
|
46
|
+
* @param {*} minOnAfterMaxOffInARow Min number of hours that must be on after maxOffInARow is saved
|
|
47
|
+
* @param {*} minSaving Minimum amount that must be saved in order to turn off
|
|
48
|
+
* @param {*} lastValueDayBefore Value of the last hour the day before
|
|
49
|
+
* @param {*} lastCountDayBefore Number of lastValueDayBefore in a row
|
|
50
|
+
* @returns Array with same number of values as in values array, where true is on, false is off
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
function calculate(
|
|
54
|
+
values,
|
|
55
|
+
maxOffInARow,
|
|
56
|
+
minOnAfterMaxOffInARow,
|
|
57
|
+
minSaving,
|
|
58
|
+
lastValueDayBefore = undefined,
|
|
59
|
+
lastCountDayBefore = 0
|
|
60
|
+
) {
|
|
61
|
+
const dayBefore = fillArray(lastValueDayBefore, lastCountDayBefore);
|
|
62
|
+
const last = values.length - 1;
|
|
63
|
+
|
|
64
|
+
// Create matrix with saving per hour
|
|
65
|
+
const savingPerHour = [];
|
|
66
|
+
for (let hour = 0; hour < last; hour++) {
|
|
67
|
+
const row = [];
|
|
68
|
+
for (let count = 1; count <= maxOffInARow; count++) {
|
|
69
|
+
const on = hour + count;
|
|
70
|
+
const saving = values[hour] - values[on >= last ? last : on];
|
|
71
|
+
row.push(saving);
|
|
72
|
+
}
|
|
73
|
+
savingPerHour.push(row);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Create list with summary saving per sequence
|
|
77
|
+
let savingsList = [];
|
|
78
|
+
for (let hour = 0; hour < last; hour++) {
|
|
79
|
+
for (let count = 1; count <= maxOffInARow; count++) {
|
|
80
|
+
let saving = 0;
|
|
81
|
+
for (let offset = 0; offset < count && hour + offset < last; offset++) {
|
|
82
|
+
saving += savingPerHour[hour + offset][count - offset - 1];
|
|
83
|
+
}
|
|
84
|
+
if (saving > minSaving * count && hour + count <= last && values[hour] > values[hour + count] + minSaving) {
|
|
85
|
+
savingsList.push({ hour, count, saving });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
savingsList.sort((a, b) => b.saving - a.saving);
|
|
91
|
+
let onOff = values.map((v) => true); // Start with all on
|
|
92
|
+
|
|
93
|
+
// Find the best possible sequences
|
|
94
|
+
while (savingsList.length > 0) {
|
|
95
|
+
const { hour, count } = savingsList[0];
|
|
96
|
+
const onOffCopy = [...onOff];
|
|
97
|
+
for (let c = 0; c < count; c++) {
|
|
98
|
+
onOff[hour + c] = false;
|
|
99
|
+
}
|
|
100
|
+
if (isOnOffSequencesOk([...dayBefore, ...onOff], maxOffInARow, minOnAfterMaxOffInARow)) {
|
|
101
|
+
savingsList = savingsList.filter((s) => s.hour < hour || s.hour >= hour + count);
|
|
102
|
+
} else {
|
|
103
|
+
onOff = [...onOffCopy];
|
|
104
|
+
savingsList.splice(0, 1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return onOff;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { calculate, isOnOffSequencesOk };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType("ps-strategy-best-save", {
|
|
3
|
+
category: "Power Saver",
|
|
4
|
+
color: "#a6bbcf",
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: "Best Save" },
|
|
7
|
+
maxHoursToSaveInSequence: {
|
|
8
|
+
value: 3,
|
|
9
|
+
required: true,
|
|
10
|
+
validate: RED.validators.number(),
|
|
11
|
+
},
|
|
12
|
+
minHoursOnAfterMaxSequenceSaved: {
|
|
13
|
+
value: 2,
|
|
14
|
+
required: true,
|
|
15
|
+
validate: RED.validators.number(),
|
|
16
|
+
},
|
|
17
|
+
minSaving: {
|
|
18
|
+
value: 0.01,
|
|
19
|
+
required: true,
|
|
20
|
+
validate: RED.validators.number(),
|
|
21
|
+
},
|
|
22
|
+
sendCurrentValueWhenRescheduling: {
|
|
23
|
+
value: true,
|
|
24
|
+
required: true,
|
|
25
|
+
// validate: RED.validators.number(),
|
|
26
|
+
align: "left",
|
|
27
|
+
},
|
|
28
|
+
outputIfNoSchedule: { value: "true", required: true, align: "left" },
|
|
29
|
+
scheduleOnlyFromCurrentTime: {
|
|
30
|
+
value: "true",
|
|
31
|
+
required: true,
|
|
32
|
+
align: "left",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
inputs: 1,
|
|
36
|
+
outputs: 3,
|
|
37
|
+
icon: "font-awesome/fa-bar-chart",
|
|
38
|
+
color: "#FFCC66",
|
|
39
|
+
label: function () {
|
|
40
|
+
return this.name || "Best Save";
|
|
41
|
+
},
|
|
42
|
+
outputLabels: ["on", "off", "schedule"],
|
|
43
|
+
oneditprepare: function () {
|
|
44
|
+
$("#node-input-outputIfNoSchedule").typedInput({
|
|
45
|
+
types: [
|
|
46
|
+
{
|
|
47
|
+
value: "onoff",
|
|
48
|
+
options: [
|
|
49
|
+
{ value: "true", label: "On" },
|
|
50
|
+
{ value: "false", label: "Off" },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
$("#node-input-scheduleOnlyFromCurrentTime").typedInput({
|
|
56
|
+
types: [
|
|
57
|
+
{
|
|
58
|
+
value: "nowOrStart",
|
|
59
|
+
options: [
|
|
60
|
+
{ value: "false", label: "Whole data set" },
|
|
61
|
+
{ value: "true", label: "From current hour" },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<script type="text/html" data-template-name="ps-strategy-best-save">
|
|
71
|
+
<div class="form-row">
|
|
72
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
73
|
+
<input type="text" id="node-input-name" placeholder="Name" style="width: 240px">
|
|
74
|
+
</div>
|
|
75
|
+
<h3>Rules</h3>
|
|
76
|
+
<div class="form-row">
|
|
77
|
+
<label for="node-input-maxHoursToSaveInSequence"><i class="fa fa-arrows-h"></i> Max per sequence</label>
|
|
78
|
+
<input type="text" id="node-input-maxHoursToSaveInSequence" style="width: 80px" placeholder="Max hours to save in sequence">
|
|
79
|
+
</div>
|
|
80
|
+
<div class="form-row">
|
|
81
|
+
<label for="node-input-minHoursOnAfterMaxSequenceSaved"><i class="fa fa-ellipsis-h"></i> Min recover</label>
|
|
82
|
+
<input type="text"
|
|
83
|
+
id="node-input-minHoursOnAfterMaxSequenceSaved"
|
|
84
|
+
style="width: 80px"
|
|
85
|
+
placeholder="Min hours on after a max sequence">
|
|
86
|
+
</div>
|
|
87
|
+
<div class="form-row">
|
|
88
|
+
<label for="node-input-minSaving"><i class="fa fa-eur"></i> Min saving</label>
|
|
89
|
+
<input type="text" id="node-input-minSaving" placeholder="Minimum to save for turning off" style="width: 80px">
|
|
90
|
+
</div>
|
|
91
|
+
<div class="form-row">
|
|
92
|
+
<label for="node-input-scheduleOnlyFromCurrentTime">Schedule for</label>
|
|
93
|
+
<input type="text" id="node-input-scheduleOnlyFromCurrentTime" style="width: 160px">
|
|
94
|
+
</label>
|
|
95
|
+
</div>
|
|
96
|
+
<h3>Output</h3>
|
|
97
|
+
<div class="form-row">
|
|
98
|
+
<label for="node-input-sendCurrentValueWhenRescheduling" style="width:240px">
|
|
99
|
+
<input type="checkbox"
|
|
100
|
+
id="node-input-sendCurrentValueWhenRescheduling"
|
|
101
|
+
style="display:inline-block; width:22px; vertical-align:top;"
|
|
102
|
+
autocomplete="off"><span>Send when rescheduling</span>
|
|
103
|
+
</label>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="form-row">
|
|
106
|
+
<label for="node-input-outputIfNoSchedule">If no schedule, send</label>
|
|
107
|
+
<input type="text" id="node-input-outputIfNoSchedule" style="width: 80px">
|
|
108
|
+
</label>
|
|
109
|
+
</div>
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<script type="text/markdown" data-help-name="ps-strategy-best-save">
|
|
113
|
+
A node you can use to save money by turning off and on a switch based on power prices.
|
|
114
|
+
|
|
115
|
+
Please read more in the [node documentation](https://ottopaulsen.github.io/node-red-contrib-power-saver/nodes/ps-strategy-best-save)
|
|
116
|
+
</script>
|