node-red-contrib-power-saver 2.0.3 → 3.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 (170) hide show
  1. package/CHANGELOG.md +1 -25
  2. package/README.md +3 -157
  3. package/docs/.vuepress/config.js +67 -0
  4. package/docs/.vuepress/dist/404.html +15 -0
  5. package/docs/.vuepress/dist/assets/css/styles.e835bef6.css +8 -0
  6. package/docs/.vuepress/dist/assets/img/back-to-top.8b37f773.svg +1 -0
  7. package/docs/.vuepress/dist/assets/img/elvia-config-no-config.b4bb972c.png +0 -0
  8. package/docs/.vuepress/dist/assets/img/elvia-config-no-tariff.3f89aba8.png +0 -0
  9. package/docs/.vuepress/dist/assets/img/elvia-config-select-tariff.0f73fd56.png +0 -0
  10. package/docs/.vuepress/dist/assets/img/elvia-config-subscription-key.8be8ab8a.png +0 -0
  11. package/docs/.vuepress/dist/assets/img/elvia-flow.bae2a4d5.png +0 -0
  12. package/docs/.vuepress/dist/assets/img/example-flow-1.3ff3e23f.png +0 -0
  13. package/docs/.vuepress/dist/assets/img/example-flow-2.b653b58d.png +0 -0
  14. package/docs/.vuepress/dist/assets/img/migrate-best-save.f73420f6.png +0 -0
  15. package/docs/.vuepress/dist/assets/img/migrate-power-saver.aae13f9d.png +0 -0
  16. package/docs/.vuepress/dist/assets/img/node-power-saver.51ff2e5d.png +0 -0
  17. package/docs/.vuepress/dist/assets/img/node-ps-elvia-add-tariff.94ea2b09.png +0 -0
  18. package/docs/.vuepress/dist/assets/img/node-ps-receive-price.76eaa418.png +0 -0
  19. package/docs/.vuepress/dist/assets/img/node-ps-strategy-best-save.392292d5.png +0 -0
  20. package/docs/.vuepress/dist/assets/img/node-ps-strategy-lowest-price.3a4ad347.png +0 -0
  21. package/docs/.vuepress/dist/assets/img/power-saver-nordpool-current-state.bf14afde.png +0 -0
  22. package/docs/.vuepress/dist/assets/img/power-saver-nordpool-events-state.8c392507.png +0 -0
  23. package/docs/.vuepress/dist/assets/img/power-saver-tibber-mqtt.16891dd2.png +0 -0
  24. package/docs/.vuepress/dist/assets/js/293.5e967839.js +1 -0
  25. package/docs/.vuepress/dist/assets/js/491.c183eba3.js +1 -0
  26. package/docs/.vuepress/dist/assets/js/812.79dad458.js +2 -0
  27. package/docs/.vuepress/dist/assets/js/812.79dad458.js.LICENSE.txt +8 -0
  28. package/docs/.vuepress/dist/assets/js/app.80d4373d.js +1 -0
  29. package/docs/.vuepress/dist/assets/js/runtime~app.665b411c.js +1 -0
  30. package/docs/.vuepress/dist/assets/js/v-08683c60.9edcaa60.js +1 -0
  31. package/docs/.vuepress/dist/assets/js/v-0aca7ba6.5eca5160.js +1 -0
  32. package/docs/.vuepress/dist/assets/js/v-0b5e3c8c.c02472fb.js +1 -0
  33. package/docs/.vuepress/dist/assets/js/v-1ad821fa.a628e907.js +1 -0
  34. package/docs/.vuepress/dist/assets/js/v-30acb564.80b4190d.js +1 -0
  35. package/docs/.vuepress/dist/assets/js/v-3706649a.d7f73384.js +1 -0
  36. package/docs/.vuepress/dist/assets/js/v-4637f9e4.df94c6ea.js +1 -0
  37. package/docs/.vuepress/dist/assets/js/v-510ed0d4.a78d8542.js +1 -0
  38. package/docs/.vuepress/dist/assets/js/v-5954bcb2.4f4712f3.js +1 -0
  39. package/docs/.vuepress/dist/assets/js/v-5db8da3a.2e49b81a.js +1 -0
  40. package/docs/.vuepress/dist/assets/js/v-61f728ca.7b545524.js +1 -0
  41. package/docs/.vuepress/dist/assets/js/v-677dfaed.756e0fb5.js +1 -0
  42. package/docs/.vuepress/dist/assets/js/v-7c87f26e.57507077.js +1 -0
  43. package/docs/.vuepress/dist/assets/js/v-8daa1a0e.1ea39527.js +1 -0
  44. package/docs/.vuepress/dist/assets/js/v-b4a42144.a812c440.js +1 -0
  45. package/docs/.vuepress/dist/assets/js/v-e8c55052.30f30acd.js +1 -0
  46. package/docs/.vuepress/dist/assets/js/v-fffb8e28.850019c1.js +1 -0
  47. package/docs/.vuepress/dist/changelog/index.html +15 -0
  48. package/docs/.vuepress/dist/contribute/index.html +15 -0
  49. package/docs/.vuepress/dist/euro.png +0 -0
  50. package/docs/.vuepress/dist/examples/example-nordpool-current-state.html +169 -0
  51. package/docs/.vuepress/dist/examples/example-nordpool-events-state.html +173 -0
  52. package/docs/.vuepress/dist/examples/example-tibber-mqtt.html +182 -0
  53. package/docs/.vuepress/dist/examples/index.html +15 -0
  54. package/docs/.vuepress/dist/guide/index.html +52 -0
  55. package/docs/.vuepress/dist/index.html +15 -0
  56. package/docs/.vuepress/dist/logo.png +0 -0
  57. package/docs/.vuepress/dist/nodes/index.html +15 -0
  58. package/docs/.vuepress/dist/nodes/old-power-saver-doc.html +97 -0
  59. package/docs/.vuepress/dist/nodes/power-saver.html +15 -0
  60. package/docs/.vuepress/dist/nodes/ps-elvia-add-tariff.html +15 -0
  61. package/docs/.vuepress/dist/nodes/ps-receive-price.html +80 -0
  62. package/docs/.vuepress/dist/nodes/ps-strategy-best-save.html +65 -0
  63. package/docs/.vuepress/dist/nodes/ps-strategy-lowest-price.html +89 -0
  64. package/docs/.vuepress/dist/nodes/strategy-input.html +40 -0
  65. package/docs/.vuepress/public/euro.png +0 -0
  66. package/docs/.vuepress/public/logo.png +0 -0
  67. package/docs/README.md +32 -0
  68. package/docs/changelog/README.md +55 -0
  69. package/docs/contribute/README.md +39 -0
  70. package/docs/examples/README.md +5 -0
  71. package/docs/examples/example-nordpool-current-state.md +166 -0
  72. package/docs/examples/example-nordpool-events-state.md +170 -0
  73. package/docs/examples/example-tibber-mqtt.md +179 -0
  74. package/docs/guide/README.md +193 -0
  75. package/docs/images/all-nodes.png +0 -0
  76. package/docs/images/best-save-config.png +0 -0
  77. package/docs/images/elvia-add-tariff-node-used.png +0 -0
  78. package/docs/images/elvia-config-no-config.png +0 -0
  79. package/docs/images/elvia-config-no-tariff.png +0 -0
  80. package/docs/images/elvia-config-select-tariff.png +0 -0
  81. package/docs/images/elvia-config-subscription-key.png +0 -0
  82. package/docs/images/elvia-flow.png +0 -0
  83. package/docs/images/elvia-tariff-config.png +0 -0
  84. package/docs/images/euro.png +0 -0
  85. package/docs/images/example-flow-1.png +0 -0
  86. package/docs/images/example-flow-2.png +0 -0
  87. package/docs/images/logo.png +0 -0
  88. package/docs/images/lowest-price-config.png +0 -0
  89. package/docs/images/migrate-best-save.png +0 -0
  90. package/docs/images/migrate-power-saver.png +0 -0
  91. package/docs/images/node-power-saver.png +0 -0
  92. package/docs/images/node-ps-elvia-add-tariff.png +0 -0
  93. package/docs/images/node-ps-elvia-tariff-types.png +0 -0
  94. package/docs/images/node-ps-elvia-tariff.png +0 -0
  95. package/docs/images/node-ps-receive-price.png +0 -0
  96. package/docs/images/node-ps-strategy-best-save.png +0 -0
  97. package/docs/images/node-ps-strategy-lowest-price.png +0 -0
  98. package/{doc → docs/images}/node-red-contrib-power-saver-flow.png +0 -0
  99. package/docs/images/power-saver-nordpool-current-state.png +0 -0
  100. package/docs/images/power-saver-nordpool-events-state.png +0 -0
  101. package/docs/images/power-saver-tibber-mqtt.png +0 -0
  102. package/docs/nodes/README.md +53 -0
  103. package/docs/nodes/old-power-saver-doc.md +231 -0
  104. package/docs/nodes/power-saver.md +23 -0
  105. package/docs/nodes/ps-elvia-add-tariff.md +52 -0
  106. package/docs/nodes/ps-receive-price.md +153 -0
  107. package/docs/nodes/ps-strategy-best-save.md +142 -0
  108. package/docs/nodes/ps-strategy-lowest-price.md +165 -0
  109. package/docs/nodes/strategy-input.md +39 -0
  110. package/package.json +19 -4
  111. package/src/elvia/elvia-add-tariff.html +70 -0
  112. package/src/elvia/elvia-add-tariff.js +47 -0
  113. package/src/elvia/elvia-api.js +61 -0
  114. package/src/elvia/elvia-config.html +44 -0
  115. package/src/elvia/elvia-config.js +25 -0
  116. package/src/elvia/elvia-tariff-types.html +34 -0
  117. package/src/elvia/elvia-tariff-types.js +23 -0
  118. package/src/elvia/elvia-tariff.html +89 -0
  119. package/src/elvia/elvia-tariff.js +26 -0
  120. package/src/elvia/icons/elvia_hvite.svg +4 -0
  121. package/src/elvia/icons/elvia_positive_4 copy.svg +4 -0
  122. package/src/handle-input.js +159 -0
  123. package/src/power-saver.html +116 -0
  124. package/{power-saver.js → src/power-saver.js} +90 -72
  125. package/src/receive-price-functions.js +99 -0
  126. package/src/receive-price.html +30 -0
  127. package/src/receive-price.js +21 -0
  128. package/src/strategy-best-save-functions.js +110 -0
  129. package/src/strategy-best-save.html +116 -0
  130. package/src/strategy-best-save.js +95 -0
  131. package/src/strategy-lowest-price-functions.js +35 -0
  132. package/src/strategy-lowest-price.html +168 -0
  133. package/src/strategy-lowest-price.js +125 -0
  134. package/{utils.js → src/utils.js} +59 -104
  135. package/test/data/adjustedResult.js +302 -0
  136. package/test/data/adjustedResult_old.js +154 -0
  137. package/test/data/best-save-result.json +357 -0
  138. package/test/data/converted-prices.json +196 -0
  139. package/test/data/elvia-input-grid-tariff.json +760 -0
  140. package/test/data/elvia-input-power-prices.json +194 -0
  141. package/test/data/elvia-output-add-tariff.json +290 -0
  142. package/test/data/lowest-price-result-cont.json +18 -0
  143. package/test/data/lowest-price-result-split-allday.json +21 -0
  144. package/test/data/lowest-price-result-split-allday10.json +20 -0
  145. package/test/data/lowest-price-result-split.json +20 -0
  146. package/test/data/nordpool-current-state-prices.json +283 -0
  147. package/test/data/nordpool-event-prices.json +574 -0
  148. package/test/data/reconfigResult.js +315 -0
  149. package/test/data/reconfigResult_old.js +141 -0
  150. package/test/data/result.js +1 -0
  151. package/test/data/tibber-prices-single-home.json +64 -0
  152. package/test/data/tibber-prices.json +124 -0
  153. package/test/data/{tibber_result.json → tibber-result.json} +2 -1
  154. package/test/elvia.test.js +26 -0
  155. package/test/mostSavedStrategy.test.js +22 -55
  156. package/test/power-saver.test.js +4 -38
  157. package/test/receive-price-functions.test.js +153 -0
  158. package/test/receive-price.test.js +122 -0
  159. package/test/send-config-input.test.js +121 -0
  160. package/test/strategy-best-save-test-utils.js +32 -0
  161. package/test/strategy-best-save.test.js +103 -0
  162. package/test/strategy-lowest-price-functions.test.js +40 -0
  163. package/test/strategy-lowest-price.test.js +338 -0
  164. package/test/test-utils.js +106 -0
  165. package/test/utils.test.js +53 -163
  166. package/mostSavedStrategy.js +0 -84
  167. package/mostSavedStrategy_v2.js +0 -68
  168. package/power-saver.html +0 -259
  169. package/test/data/tibber_data.json +0 -412
  170. 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 mostSavedStrategy = require("./mostSavedStrategy");
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
- // Save config in node
19
- this.maxHoursToSaveInSequence = config.maxHoursToSaveInSequence;
20
- this.minHoursOnAfterMaxSequenceSaved =
21
- config.minHoursOnAfterMaxSequenceSaved;
22
- this.minSaving = parseFloat(config.minSaving);
23
- this.sendCurrentValueWhenRescheduling =
24
- config.sendCurrentValueWhenRescheduling;
25
- this.outputIfNoSchedule = config.outputIfNoSchedule === "true";
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
- if (!validateMsg(node, msg)) {
33
- return;
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
- const priceData = [...input.today, ...input.tomorrow];
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 values = priceData.map((d) => d.value);
55
- const startTimes = priceData.map((d) => d.start);
56
- const plan = makePlan(node, values, startTimes, dataDayBefore.onOff);
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: input.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 time = msg.payload.time
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 (outputCurrent && pastSchedule.length > 0) {
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, time);
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 loadDayData(node, date) {
111
- // Load saved schedule for the date (YYYY-MM-DD)
112
- // Return null if not found
113
- const key = date.toISO();
114
- return (
115
- node.context().get(key) || {
116
- values: [],
117
- onOff: [],
118
- startTimes: [],
119
- schedule: [],
120
- savings: [],
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
- ? mostSavedStrategy.calculate(
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.length
228
- } changes. Next: ${remainingSchedule[0].value ? "on" : "off"}`;
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="boolean" 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="boolean" 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>