node-red-contrib-energymeterplus 0.3.5 → 0.3.6
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/README.md +80 -165
- package/energyMeterPlus.html +135 -57
- package/energyMeterPlus.js +136 -107
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,224 +1,139 @@
|
|
|
1
|
-
|
|
1
|
+
##### **EnergyMeterPlus** Node Version 1.0.1
|
|
2
|
+
@ arcfrankye 26-06-26
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
###### Overview
|
|
7
|
+
**EnergyMeterPlus** node converts instantaneous power readings into accumulated energy totals and cost, with robust baseline handling and safer editor UX. **EnergyMeterPlus** integrates power over time, applies one-time baseline offsets, prevents accidental double application, handles rollovers, persists state, and writes snapshots to disk.
|
|
6
8
|
|
|
9
|
+
Version numbers indicate the quality of the build. Main numbers are used for stable and tested builds; sub numbers are used for test editions still undergoing debugging. Use **EnergyMeterPlus** at your own risk.
|
|
7
10
|
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
###### Key Features
|
|
13
|
+
* **Time integration** of power (W or kW) into energy (kWh).
|
|
14
|
+
* **Daily, weekly, monthly, yearly** energy totals.
|
|
15
|
+
* **Cost calculations** using a configurable unit cost and currency symbol.
|
|
16
|
+
* **One-time baseline offsets**: editor lets you apply offsets once; runtime applies only the delta and stores what was applied.
|
|
17
|
+
* **Editor UX improvements**: inputs show saved values, an **Apply** button greys inputs and marks them applied, and a **Reset** button re-enables inputs.
|
|
18
|
+
* **Safe deploy behavior**: editor changes are saved to node config; runtime applies offsets on Deploy only when flagged applied.
|
|
19
|
+
* **Runtime messages for immediate control**: `applyBaseline` and `resetEditorApplied`.
|
|
20
|
+
* **Rollover handling** for day, week, month, year, with yearly archiving.
|
|
21
|
+
* **Persistence**: counters and timestamps in Node-RED context; snapshots written to JSON file; directories auto-created.
|
|
22
|
+
* **Defensive parsing and logging**: numeric guards, error handling, and clear log messages for deploy and runtime actions.
|
|
10
23
|
|
|
11
24
|
|
|
25
|
+
###### Configuration Options
|
|
12
26
|
|
|
27
|
+
**Unit Cost**:
|
|
28
|
+
* Cost per kWh (e.g., `0.15` for $0.15/kWh).
|
|
13
29
|
|
|
30
|
+
**Currency**:
|
|
31
|
+
* Choose USD, EUR, or NGN. The node uses a symbol for display, no currency conversion.
|
|
14
32
|
|
|
15
|
-
|
|
33
|
+
**File Path**:
|
|
34
|
+
* Path to write snapshots (default: `/config/node\\\\\\\_red/solargen\\\\\\\_data.json`). Directories are created automatically.
|
|
16
35
|
|
|
36
|
+
**Input Unit**:
|
|
37
|
+
* "W" (Watts) or "kW" (kilowatts). If using Watts, the node converts to kW internally.
|
|
17
38
|
|
|
18
39
|
|
|
19
|
-
|
|
40
|
+
**Baseline Offsets**
|
|
41
|
+
* Baseline Daily, Weekly, Monthly, Yearly: enter offsets in the editor.
|
|
20
42
|
|
|
21
43
|
|
|
44
|
+
**Apply Workflow**:
|
|
45
|
+
* Click **Apply** in the editor to mark offsets as applied (inputs grey out). Click **Done,** then **Deploy** to persist and have the runtime apply the offsets once.
|
|
22
46
|
|
|
23
|
-
* Tracks daily, weekly, monthly, and yearly energy totals.
|
|
24
47
|
|
|
48
|
+
**Reset**:
|
|
49
|
+
* Click **Reset** in the editor to re-enable inputs; on the next **Deploy,** the runtime will allow reapply. You can also send a runtime reset message to clear memory immediately.
|
|
25
50
|
|
|
26
51
|
|
|
27
|
-
|
|
52
|
+
**Runtime Messages:**
|
|
53
|
+
* Immediate apply without **Deploy**. Send a message to the node: json { "topic": "applyBaseline", "payload": { "daily": 2, "weekly": 0, "monthly": 0, "yearly": 0 } }. This applies the provided offsets immediately (additive).
|
|
28
54
|
|
|
29
55
|
|
|
56
|
+
**Clear Remembered Editor Application**:
|
|
57
|
+
* Send a message to the node: json { "topic": "resetEditorApplied" }. This clears the runtime `lastAppliedEditor` so the same editor values can be applied again on the next **Deploy**.
|
|
30
58
|
|
|
31
|
-
* Unit cost configurable in 3 different currencies
|
|
32
59
|
|
|
60
|
+
###### Output Payload
|
|
61
|
+
The node outputs a `msg.payload` object with rounded values (two decimals):
|
|
33
62
|
|
|
63
|
+
 "energyDaily": 4.53,
|
|
34
64
|
|
|
35
|
-
|
|
65
|
+
 "energyWeekly": 32.18,
|
|
36
66
|
|
|
67
|
+
 "energyMonthly": 128.74,
|
|
37
68
|
|
|
69
|
+
 "energyYearly": 1024.56,
|
|
38
70
|
|
|
39
|
-
|
|
71
|
+
 "daily\\\\\\\_cost": 0.68,
|
|
40
72
|
|
|
73
|
+
 "weekly\\\\\\\_cost": 4.83,
|
|
41
74
|
|
|
75
|
+
 "monthly\\\\\\\_cost": 19.31,
|
|
42
76
|
|
|
43
|
-
|
|
77
|
+
 "yearly\\\\\\\_cost": 153.68,
|
|
44
78
|
|
|
79
|
+
 "currency": "?"
|
|
45
80
|
|
|
81
|
+
Note: field names are `energyDaily`, `energyWeekly`, `energyMonthly`, `energyYearly` in the runtime implementation.
|
|
46
82
|
|
|
47
|
-
* Outputs clean values rounded to two decimal places for dashboards.
|
|
48
83
|
|
|
84
|
+
###### Rollover Rules
|
|
85
|
+
* **Daily** resets at midnight (local time).
|
|
86
|
+
* **Weekly** resets when the week boundary is reached (implementation assumes the week starts on Mondays).
|
|
87
|
+
* **Monthly** resets on the month change.
|
|
88
|
+
* **Yearly** resets on year change and archives the previous year's total to `yearly\_archive.json`.
|
|
49
89
|
|
|
50
90
|
|
|
91
|
+
###### Persistence and Safety
|
|
92
|
+
* **Context storage**: baseline, accumulated totals, last check timestamp, and `lastAppliedEditor` are stored in Node-RED context.
|
|
93
|
+
* **File snapshots**: written to the configured JSON file on each output.
|
|
94
|
+
* **Robustness**: numeric parsing guards, try/catch around file I/O, and logs for deploy/runtime actions to help debugging.
|
|
51
95
|
|
|
52
96
|
|
|
53
|
-
|
|
97
|
+
###### Editor UX Behavior
|
|
98
|
+
* **Inputs** show saved values when opening the editor, so you can see what was last applied.
|
|
99
|
+
* **Apply** button greys inputs and sets an `applied` flag; click \*\*Done\*\* then \*\***Deploy**\*\* to have runtime apply offsets once.
|
|
100
|
+
* **Reset** button re-enables inputs and clears the `applied` flag; \*\*Deploy\*\* will then allow reapply.
|
|
101
|
+
* **One-time semantics**: runtime computes `delta = editor - lastAppliedEditor` and applies only non-zero delta. Repeated \*\*Deploy\*\*s with identical editor values do not reapply offsets.
|
|
54
102
|
|
|
55
103
|
|
|
104
|
+
###### Troubleshooting Tips
|
|
105
|
+
* If baseline edits do not mark the flow dirty, ensure the editor `oneditsave` saves values to `this.baseline` and `this.applied`.
|
|
106
|
+
* Use **Full Deploy** when testing editor/runtime changes to ensure constructors run.
|
|
107
|
+
* Check Node-RED logs for messages like `Editor baseline applied on deploy (delta)` or `Comms applyBaseline: no delta to apply`.
|
|
108
|
+
* If you need immediate effect without **Deploy**, use the `applyBaseline` inject message shown above.
|
|
56
109
|
|
|
57
|
-
Unit Cost: Cost per kWh (e.g., 0.15 for $0.15/kWh).
|
|
58
110
|
|
|
111
|
+
###### Examples
|
|
112
|
+
* **Apply** 5 kWh daily offset via editor: enter `5` in Baseline Daily, click **Apply**, **Done**, then **Deploy**. Runtime will add the delta once.
|
|
113
|
+
* **Immediate runtime apply**: inject `{ topic: "applyBaseline", payload: { "daily": 2 } }` to add 2 kWh immediately.
|
|
59
114
|
|
|
60
115
|
|
|
61
|
-
|
|
116
|
+
###### Changelog
|
|
117
|
+
**v1.0.1**
|
|
118
|
+
* Major UI and runtime overhaul. Editor shows saved values, adds Apply and Reset controls, greys inputs after Apply, and persists an `applied` flag. Runtime applies editor offsets exactly once using delta logic and stores `lastAppliedEditor`. Added safer rollover and logging.
|
|
62
119
|
|
|
120
|
+
**v0.2.5**
|
|
121
|
+
* Fixed internal counter bug.
|
|
63
122
|
|
|
123
|
+
**v0.2.4**
|
|
124
|
+
* Fixed incremental counter update bug. Added payload validation. Baselines captured once and survive UI clearing.
|
|
64
125
|
|
|
65
|
-
|
|
126
|
+
**v0.2.3**
|
|
127
|
+
* Fixed baseline visibility bug after applying. Fixed rollover double-counting. Baselines are applied once and stored in context. Counters updated continuously.
|
|
66
128
|
|
|
129
|
+
**v0.0.3**
|
|
130
|
+
* Initial release: integrates power to energy, cost calc, rollovers, persistence, and basic baseline support.
|
|
67
131
|
|
|
68
132
|
|
|
69
|
-
Input Unit: "W" for Watts (default) or "kW" if your source already provides kilowatts.
|
|
70
133
|
|
|
71
134
|
|
|
72
135
|
|
|
73
|
-
Baselines: Initial values for daily, weekly, monthly, and yearly counters. Can also be used to correct baseline values or left blank if not needed. Baseline values are applied once at startup or upon a config change and are cleared from the UI. New values can be entered as needed, and the cycle repeats.
|
|
74
136
|
|
|
75
137
|
|
|
76
138
|
|
|
77
139
|
|
|
78
|
-
|
|
79
|
-
###### **Output:**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
The node outputs a payload object:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
json
|
|
88
|
-
|
|
89
|
-
{
|
|
90
|
-
|
|
91
|
-
  "daily\_kWh": 4.53,
|
|
92
|
-
|
|
93
|
-
  "weekly\_kWh": 32.18,
|
|
94
|
-
|
|
95
|
-
  "monthly\_kWh": 128.74,
|
|
96
|
-
|
|
97
|
-
  "yearly\_kWh": 1024.56,
|
|
98
|
-
|
|
99
|
-
  "daily\_cost": 0.68,
|
|
100
|
-
|
|
101
|
-
  "weekly\_cost": 4.83,
|
|
102
|
-
|
|
103
|
-
  "monthly\_cost": 19.31,
|
|
104
|
-
|
|
105
|
-
  "yearly\_cost": 153.68
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
All values are rounded to two decimal places.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
###### **Rollover Logic:**
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
* Daily resets at midnight.
|
|
120
|
-
*
|
|
121
|
-
* Weekly resets on Sunday.
|
|
122
|
-
*
|
|
123
|
-
* Monthly resets on the first of the month.
|
|
124
|
-
*
|
|
125
|
-
* Yearly resets on January 1st and archives the previous year’s totals.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
###### **Persistence:**
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
Counters and timestamps are stored in Node‑RED context.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
Snapshots are written to the configured JSON file.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
Directories are auto‑created if missing, ensuring no ENOENT errors.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
##### **Example:**
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
If your solar system outputs \~4526 W continuously:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
After 1 hour → \~4.53 kWh added to daily total.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
After 24 hours → \~108.6 kWh added to daily total.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
Costs scale automatically with your configured unit cost.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
##### **Updates:**
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
###### **v2.3:**
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
Cleared bug where baseline values remain visible after applying the values
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
Cleared the rollover logic to eliminate double-counting.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
Baselines now applied once and stored in context.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
updated counters to continuous absorption, no transfer at rollover
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
###### **v2.4**
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
Fixed a bug where the internal counters were not updating incrementally.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
Added payload validation
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
Fixed Baselines bug: Baselines captured once, stored in context, and survive after UI clears
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
###### **v2.5**
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
Fixed internal counter bug
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
###### **v3.0**
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
**Major overhaul of UI and internal logic programming to fix bugs in counters and baseline corrections**
|
|
224
|
-
|
package/energyMeterPlus.html
CHANGED
|
@@ -1,67 +1,134 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* energyMeterPlus Editor UX #arcfrankye - 27/06/2026
|
|
3
|
+
*/
|
|
1
4
|
|
|
2
5
|
<script type="text/javascript">
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// Feedback logic
|
|
39
|
-
if (daily === 0 && weekly === 0 && monthly === 0 && yearly === 0) {
|
|
40
|
-
RED.notify("⚠️ Baseline values are all zero — nothing applied", {type:"warning", timeout:3000});
|
|
41
|
-
} else if (isNaN(daily) || isNaN(weekly) || isNaN(monthly) || isNaN(yearly)) {
|
|
42
|
-
RED.notify("❌ Baseline values invalid — please enter numbers", {type:"error", timeout:4000});
|
|
6
|
+
RED.nodes.registerType('energyMeterPlus', {
|
|
7
|
+
category: 'energy',
|
|
8
|
+
color: '#f3a108fe',
|
|
9
|
+
defaults: {
|
|
10
|
+
name: {value:""},
|
|
11
|
+
baselineDaily: {value:0, required:false},
|
|
12
|
+
baselineWeekly: {value:0, required:false},
|
|
13
|
+
baselineMonthly: {value:0, required:false},
|
|
14
|
+
baselineYearly: {value:0, required:false},
|
|
15
|
+
applied: {value:false, required:false}, // NEW: whether editor values have been applied
|
|
16
|
+
unitCost: {value:0.15, required:true},
|
|
17
|
+
currency: {value:"USD", required:true},
|
|
18
|
+
filePath: {value:"/config/node_red/solargen_data.json", required:false},
|
|
19
|
+
inputUnit: {value:"W", required:true}
|
|
20
|
+
},
|
|
21
|
+
inputs:1,
|
|
22
|
+
outputs:1,
|
|
23
|
+
icon: "sun.png",
|
|
24
|
+
label: function() { return this.name || "energyMeterPlus"; },
|
|
25
|
+
|
|
26
|
+
oneditprepare: function() {
|
|
27
|
+
// show saved values in inputs
|
|
28
|
+
$("#node-input-baselineDaily").val(this.baselineDaily);
|
|
29
|
+
$("#node-input-baselineWeekly").val(this.baselineWeekly);
|
|
30
|
+
$("#node-input-baselineMonthly").val(this.baselineMonthly);
|
|
31
|
+
$("#node-input-baselineYearly").val(this.baselineYearly);
|
|
32
|
+
|
|
33
|
+
// reflect applied state visually
|
|
34
|
+
function setAppliedUI(isApplied) {
|
|
35
|
+
if (isApplied) {
|
|
36
|
+
$("#node-input-baselineDaily").prop("disabled", true).addClass("nr-disabled");
|
|
37
|
+
$("#node-input-baselineWeekly").prop("disabled", true).addClass("nr-disabled");
|
|
38
|
+
$("#node-input-baselineMonthly").prop("disabled", true).addClass("nr-disabled");
|
|
39
|
+
$("#node-input-baselineYearly").prop("disabled", true).addClass("nr-disabled");
|
|
40
|
+
$("#energy-applied-badge").text("Applied").show();
|
|
43
41
|
} else {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
$("#node-input-baselineDaily").prop("disabled", false).removeClass("nr-disabled");
|
|
43
|
+
$("#node-input-baselineWeekly").prop("disabled", false).removeClass("nr-disabled");
|
|
44
|
+
$("#node-input-baselineMonthly").prop("disabled", false).removeClass("nr-disabled");
|
|
45
|
+
$("#node-input-baselineYearly").prop("disabled", false).removeClass("nr-disabled");
|
|
46
|
+
$("#energy-applied-badge").hide();
|
|
47
47
|
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// initial UI state from saved flag
|
|
51
|
+
setAppliedUI(Boolean(this.applied));
|
|
52
|
+
|
|
53
|
+
// Apply button handler (immediate visual feedback)
|
|
54
|
+
$("#energy-apply-btn").on("click", function(e) {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
// read values
|
|
57
|
+
var d = Number($("#node-input-baselineDaily").val()) || 0;
|
|
58
|
+
var w = Number($("#node-input-baselineWeekly").val()) || 0;
|
|
59
|
+
var m = Number($("#node-input-baselineMonthly").val()) || 0;
|
|
60
|
+
var y = Number($("#node-input-baselineYearly").val()) || 0;
|
|
61
|
+
|
|
62
|
+
if (!d && !w && !m && !y) {
|
|
63
|
+
RED.notify("Baseline values are all zero — nothing to apply", {type:"warning", timeout:3000});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// disable inputs and show badge immediately
|
|
68
|
+
setAppliedUI(true);
|
|
69
|
+
|
|
70
|
+
// store a local flag so oneditsave persists it
|
|
71
|
+
$("#node-input-applied").val("true");
|
|
72
|
+
|
|
73
|
+
RED.notify("Baseline marked as applied in editor. Click Done to save and Deploy to apply runtime delta.", {type:"info", timeout:3000});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Reset button handler (re-enable inputs)
|
|
77
|
+
$("#energy-reset-applied").on("click", function(e) {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
setAppliedUI(false);
|
|
80
|
+
$("#node-input-applied").val("false");
|
|
81
|
+
RED.notify("Editor: baseline marked for reapply on next Deploy. Click Done then Deploy.", {type:"info", timeout:3000});
|
|
82
|
+
});
|
|
48
83
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
$("#node-input-
|
|
52
|
-
|
|
53
|
-
|
|
84
|
+
// small trimming on input
|
|
85
|
+
["baselineDaily","baselineWeekly","baselineMonthly","baselineYearly"].forEach(function(id){
|
|
86
|
+
$("#node-input-"+id).on("input", function(){ $(this).val($(this).val().trim()); });
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
oneditsave: function() {
|
|
91
|
+
// Save all fields so Node-RED marks node as changed
|
|
92
|
+
this.unitCost = $("#node-input-unitCost").val();
|
|
93
|
+
this.currency = $("#node-input-currency").val();
|
|
94
|
+
this.inputUnit = $("#node-input-inputUnit").val();
|
|
95
|
+
this.filePath = $("#node-input-filePath").val();
|
|
96
|
+
|
|
97
|
+
// Save baseline fields into the node config (do not clear inputs)
|
|
98
|
+
this.baselineDaily = $("#node-input-baselineDaily").val();
|
|
99
|
+
this.baselineWeekly = $("#node-input-baselineWeekly").val();
|
|
100
|
+
this.baselineMonthly = $("#node-input-baselineMonthly").val();
|
|
101
|
+
this.baselineYearly = $("#node-input-baselineYearly").val();
|
|
102
|
+
|
|
103
|
+
// Persist applied flag (string "true"/"false" from hidden input)
|
|
104
|
+
var appliedVal = $("#node-input-applied").val();
|
|
105
|
+
this.applied = (appliedVal === "true");
|
|
106
|
+
|
|
107
|
+
// Optionally request immediate runtime apply; runtime delta logic will ignore zero-delta
|
|
108
|
+
function toNum(v){ var n = Number(v); return isNaN(n) ? 0 : n; }
|
|
109
|
+
var editor = {
|
|
110
|
+
daily: toNum(this.baselineDaily),
|
|
111
|
+
weekly: toNum(this.baselineWeekly),
|
|
112
|
+
monthly: toNum(this.baselineMonthly),
|
|
113
|
+
yearly: toNum(this.baselineYearly)
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (this.applied && (editor.daily || editor.weekly || editor.monthly || editor.yearly)) {
|
|
117
|
+
// request runtime apply; runtime will only apply non-zero delta
|
|
118
|
+
RED.comms.send("energyMeterPlus/applyBaseline", { nodeId: this.id, payload: editor });
|
|
119
|
+
RED.notify("Baseline apply requested to runtime", {type:"success", timeout:2000});
|
|
54
120
|
}
|
|
55
|
-
}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
56
123
|
</script>
|
|
57
124
|
|
|
125
|
+
<!-- Template -->
|
|
58
126
|
<script type="text/x-red" data-template-name="energyMeterPlus">
|
|
59
127
|
<div class="form-row">
|
|
60
128
|
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
61
129
|
<input type="text" id="node-input-name">
|
|
62
130
|
</div>
|
|
63
131
|
|
|
64
|
-
<!-- Baseline offsets -->
|
|
65
132
|
<div class="form-row">
|
|
66
133
|
<label for="node-input-baselineDaily"><i class="fa fa-calendar-day"></i> Baseline Daily Offset</label>
|
|
67
134
|
<input type="number" id="node-input-baselineDaily" placeholder="Daily offset">
|
|
@@ -79,7 +146,19 @@
|
|
|
79
146
|
<input type="number" id="node-input-baselineYearly" placeholder="Yearly offset">
|
|
80
147
|
</div>
|
|
81
148
|
|
|
82
|
-
<!--
|
|
149
|
+
<!-- Hidden applied flag persisted on save -->
|
|
150
|
+
<input type="hidden" id="node-input-applied" value="false">
|
|
151
|
+
|
|
152
|
+
<!-- Apply and Reset controls -->
|
|
153
|
+
<div class="form-row">
|
|
154
|
+
<button id="energy-apply-btn" class="editor-button">Apply</button>
|
|
155
|
+
<button id="energy-reset-applied" class="editor-button">Reset</button>
|
|
156
|
+
<span id="energy-applied-badge" style="display:none;margin-left:8px;padding:2px 6px;background:#ddd;border-radius:4px;color:#333;font-size:0.9em;">Applied</span>
|
|
157
|
+
<div class="help">Click Apply to mark these offsets as used. Click Reset to allow reapply.</div>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<hr/>
|
|
161
|
+
|
|
83
162
|
<div class="form-row">
|
|
84
163
|
<label for="node-input-unitCost"><i class="fa fa-money"></i> Cost per Unit</label>
|
|
85
164
|
<input type="number" id="node-input-unitCost" step="0.01">
|
|
@@ -103,11 +182,10 @@
|
|
|
103
182
|
<option value="W">W</option>
|
|
104
183
|
</select>
|
|
105
184
|
</div>
|
|
106
|
-
</script>
|
|
107
185
|
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
186
|
+
<style>
|
|
187
|
+
/* subtle greyed-out look for disabled baseline inputs */
|
|
188
|
+
.nr-disabled { background:#f3f3f3 !important; color:#666 !important; }
|
|
189
|
+
.editor-button { margin-right:6px; }
|
|
190
|
+
</style>
|
|
113
191
|
</script>
|
package/energyMeterPlus.js
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* energyMeterPlus runtime
|
|
3
|
+
* - Applies editor baseline offsets exactly once when editor sets `applied` and you Deploy.
|
|
4
|
+
* - Uses lastAppliedEditor in node context to avoid double application.
|
|
5
|
+
* - Supports runtime messages:
|
|
6
|
+
* - msg.topic === "applyBaseline" with msg.payload = { daily, weekly, monthly, yearly } (immediate apply)
|
|
7
|
+
* - msg.topic === "resetEditorApplied" (clears lastAppliedEditor so same editor values can be applied again on next Deploy)
|
|
8
|
+
*/
|
|
9
|
+
|
|
1
10
|
const fs = require("fs");
|
|
2
11
|
const path = require("path");
|
|
3
12
|
|
|
4
13
|
module.exports = function(RED) {
|
|
5
14
|
function EnergyMeterPlus(config) {
|
|
6
15
|
RED.nodes.createNode(this, config);
|
|
7
|
-
|
|
16
|
+
const node = this;
|
|
17
|
+
|
|
18
|
+
// Config values (from editor)
|
|
19
|
+
const unitCost = Number(config.unitCost) || 0;
|
|
20
|
+
const filePath = config.filePath || "/config/node_red/solargen_data.json";
|
|
21
|
+
const inputUnit = config.inputUnit || "W";
|
|
22
|
+
const currencyCode = config.currency || "USD";
|
|
23
|
+
|
|
24
|
+
// Helper: safe number parse
|
|
25
|
+
function toNum(v) {
|
|
26
|
+
const n = Number(v);
|
|
27
|
+
return isNaN(n) ? 0 : n;
|
|
28
|
+
}
|
|
8
29
|
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
30
|
+
// Helper: rounding
|
|
31
|
+
function round2(val) {
|
|
32
|
+
if (val === null || val === undefined || isNaN(val)) return 0;
|
|
33
|
+
return Number(val.toFixed(2));
|
|
34
|
+
}
|
|
14
35
|
|
|
15
|
-
//
|
|
16
|
-
function toNum(v) { const n = Number(v); return isNaN(n) ? 0 : n; }
|
|
36
|
+
// Helper: currency symbol
|
|
17
37
|
function currencySymbol(code) {
|
|
18
38
|
switch (code) {
|
|
19
39
|
case "USD": return "$";
|
|
@@ -22,120 +42,108 @@ module.exports = function(RED) {
|
|
|
22
42
|
default: return code;
|
|
23
43
|
}
|
|
24
44
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
} catch (err) {
|
|
40
|
-
node.error("Failed to archive yearly total: " + err);
|
|
41
|
-
}
|
|
45
|
+
|
|
46
|
+
// Ensure baseline exists in context
|
|
47
|
+
let baseline = node.context().get("baseline") || { daily:0, weekly:0, monthly:0, yearly:0 };
|
|
48
|
+
node.context().set("baseline", baseline);
|
|
49
|
+
|
|
50
|
+
// Ensure accumulated exists in context
|
|
51
|
+
let accumulated = node.context().get("accumulated") || { daily:0, weekly:0, monthly:0, yearly:0 };
|
|
52
|
+
node.context().set("accumulated", accumulated);
|
|
53
|
+
|
|
54
|
+
// Ensure lastCheck exists and is a Date
|
|
55
|
+
let lastCheck = node.context().get("lastCheck");
|
|
56
|
+
if (!(lastCheck instanceof Date)) {
|
|
57
|
+
lastCheck = new Date(lastCheck || Date.now());
|
|
58
|
+
node.context().set("lastCheck", lastCheck);
|
|
42
59
|
}
|
|
43
60
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
61
|
+
// Ensure lastAppliedEditor exists
|
|
62
|
+
let lastAppliedEditor = node.context().get("lastAppliedEditor") || { daily:0, weekly:0, monthly:0, yearly:0 };
|
|
63
|
+
node.context().set("lastAppliedEditor", lastAppliedEditor);
|
|
47
64
|
|
|
48
|
-
|
|
65
|
+
// --- Apply editor baseline on deploy if editor flagged applied === true ---
|
|
66
|
+
try {
|
|
67
|
+
const editor = {
|
|
49
68
|
daily: toNum(config.baselineDaily),
|
|
50
69
|
weekly: toNum(config.baselineWeekly),
|
|
51
70
|
monthly: toNum(config.baselineMonthly),
|
|
52
71
|
yearly: toNum(config.baselineYearly)
|
|
53
72
|
};
|
|
54
73
|
|
|
55
|
-
|
|
56
|
-
daily: editor.daily - (toNum(lastApplied.daily) || 0),
|
|
57
|
-
weekly: editor.weekly - (toNum(lastApplied.weekly) || 0),
|
|
58
|
-
monthly: editor.monthly - (toNum(lastApplied.monthly) || 0),
|
|
59
|
-
yearly: editor.yearly - (toNum(lastApplied.yearly) || 0)
|
|
60
|
-
};
|
|
74
|
+
const appliedFlag = Boolean(config.applied);
|
|
61
75
|
|
|
62
|
-
|
|
63
|
-
|
|
76
|
+
if (appliedFlag) {
|
|
77
|
+
const last = node.context().get("lastAppliedEditor") || { daily:0, weekly:0, monthly:0, yearly:0 };
|
|
78
|
+
const delta = {
|
|
79
|
+
daily: editor.daily - toNum(last.daily),
|
|
80
|
+
weekly: editor.weekly - toNum(last.weekly),
|
|
81
|
+
monthly: editor.monthly - toNum(last.monthly),
|
|
82
|
+
yearly: editor.yearly - toNum(last.yearly)
|
|
83
|
+
};
|
|
64
84
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
baseline.daily += delta.daily;
|
|
68
|
-
baseline.weekly += delta.weekly;
|
|
69
|
-
baseline.monthly += delta.monthly;
|
|
70
|
-
baseline.yearly += delta.yearly;
|
|
85
|
+
if (delta.daily || delta.weekly || delta.monthly || delta.yearly) {
|
|
86
|
+
baseline = node.context().get("baseline") || { daily:0, weekly:0, monthly:0, yearly:0 };
|
|
71
87
|
|
|
72
|
-
|
|
73
|
-
|
|
88
|
+
baseline.daily += delta.daily;
|
|
89
|
+
baseline.weekly += delta.weekly;
|
|
90
|
+
baseline.monthly += delta.monthly;
|
|
91
|
+
baseline.yearly += delta.yearly;
|
|
74
92
|
|
|
75
|
-
|
|
76
|
-
} else {
|
|
77
|
-
// Still ensure lastAppliedEditor exists in context
|
|
78
|
-
if (!node.context().get("lastAppliedEditor")) {
|
|
93
|
+
node.context().set("baseline", baseline);
|
|
79
94
|
node.context().set("lastAppliedEditor", editor);
|
|
95
|
+
|
|
96
|
+
node.log(`Editor baseline applied on deploy (delta): ${JSON.stringify(delta)}`);
|
|
97
|
+
} else {
|
|
98
|
+
node.log("Editor baseline flagged applied on deploy but no delta to apply (values match lastAppliedEditor).");
|
|
80
99
|
}
|
|
81
|
-
|
|
100
|
+
} else {
|
|
101
|
+
node.log("Editor baseline not flagged applied on deploy; no editor delta applied.");
|
|
82
102
|
}
|
|
83
103
|
} catch (err) {
|
|
84
|
-
node.error("Error applying editor baseline
|
|
85
|
-
}
|
|
86
|
-
// -------------------------------------------------------------------------------
|
|
87
|
-
|
|
88
|
-
// Accumulated values
|
|
89
|
-
let accumulated = node.context().get("accumulated") || { daily:0, weekly:0, monthly:0, yearly:0 };
|
|
90
|
-
|
|
91
|
-
// Last check timestamp
|
|
92
|
-
let lastCheck = node.context().get("lastCheck");
|
|
93
|
-
if (!(lastCheck instanceof Date)) {
|
|
94
|
-
lastCheck = new Date(lastCheck || Date.now());
|
|
104
|
+
node.error("Error applying editor baseline on deploy: " + err);
|
|
95
105
|
}
|
|
96
106
|
|
|
97
|
-
// Rollover
|
|
98
|
-
let rolloverInterval = null;
|
|
99
|
-
|
|
100
|
-
// Rollover logic
|
|
107
|
+
// --- Rollover logic ---
|
|
101
108
|
function checkRollover() {
|
|
102
109
|
try {
|
|
103
|
-
|
|
110
|
+
const now = new Date();
|
|
104
111
|
|
|
105
112
|
if (!(lastCheck instanceof Date)) {
|
|
106
113
|
lastCheck = new Date(lastCheck || Date.now());
|
|
107
114
|
}
|
|
108
115
|
|
|
109
|
-
// daily
|
|
116
|
+
// daily rollover (date change)
|
|
110
117
|
if (now.getDate() !== lastCheck.getDate() || now.toDateString() !== lastCheck.toDateString()) {
|
|
111
118
|
accumulated.daily = 0;
|
|
112
|
-
|
|
119
|
+
baseline = node.context().get("baseline") || baseline;
|
|
113
120
|
baseline.daily = 0;
|
|
114
121
|
node.context().set("baseline", baseline);
|
|
115
122
|
node.log("Daily rollover executed.");
|
|
116
123
|
}
|
|
117
124
|
|
|
118
|
-
// weekly (assume week starts Monday)
|
|
125
|
+
// weekly rollover (assume week starts Monday)
|
|
126
|
+
// If lastCheck was not Monday and now is Monday, reset weekly
|
|
119
127
|
if (now.getDay() === 1 && lastCheck.getDay() !== 1) {
|
|
120
128
|
accumulated.weekly = 0;
|
|
121
|
-
|
|
129
|
+
baseline = node.context().get("baseline") || baseline;
|
|
122
130
|
baseline.weekly = 0;
|
|
123
131
|
node.context().set("baseline", baseline);
|
|
124
132
|
node.log("Weekly rollover executed.");
|
|
125
133
|
}
|
|
126
134
|
|
|
127
|
-
// monthly
|
|
135
|
+
// monthly rollover
|
|
128
136
|
if (now.getMonth() !== lastCheck.getMonth()) {
|
|
129
137
|
accumulated.monthly = 0;
|
|
130
|
-
|
|
138
|
+
baseline = node.context().get("baseline") || baseline;
|
|
131
139
|
baseline.monthly = 0;
|
|
132
140
|
node.context().set("baseline", baseline);
|
|
133
141
|
node.log("Monthly rollover executed.");
|
|
134
142
|
}
|
|
135
143
|
|
|
136
|
-
// yearly
|
|
144
|
+
// yearly rollover
|
|
137
145
|
if (now.getFullYear() !== lastCheck.getFullYear()) {
|
|
138
|
-
|
|
146
|
+
baseline = node.context().get("baseline") || baseline;
|
|
139
147
|
archiveYearly(baseline.yearly + accumulated.yearly, lastCheck.getFullYear());
|
|
140
148
|
accumulated.yearly = 0;
|
|
141
149
|
baseline.yearly = 0;
|
|
@@ -151,52 +159,71 @@ module.exports = function(RED) {
|
|
|
151
159
|
}
|
|
152
160
|
}
|
|
153
161
|
|
|
154
|
-
|
|
155
|
-
rolloverInterval = setInterval(checkRollover, 60000);
|
|
162
|
+
const rolloverInterval = setInterval(checkRollover, 60000);
|
|
156
163
|
|
|
157
|
-
//
|
|
158
|
-
|
|
164
|
+
// --- Archive helper ---
|
|
165
|
+
function archiveYearly(total, year) {
|
|
159
166
|
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
167
|
+
const archivePath = path.join(path.dirname(filePath), "yearly_archive.json");
|
|
168
|
+
let archive = [];
|
|
169
|
+
if (fs.existsSync(archivePath)) {
|
|
170
|
+
try {
|
|
171
|
+
archive = JSON.parse(fs.readFileSync(archivePath));
|
|
172
|
+
} catch (e) {
|
|
173
|
+
node.warn("Failed to parse existing yearly archive, starting fresh.");
|
|
174
|
+
archive = [];
|
|
175
|
+
}
|
|
165
176
|
}
|
|
177
|
+
archive.push({ year, total, timestamp: new Date().toISOString() });
|
|
178
|
+
fs.writeFileSync(archivePath, JSON.stringify(archive, null, 2));
|
|
179
|
+
node.log(`Archived yearly total for ${year}: ${total}`);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
node.error("Failed to archive yearly total: " + err);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
166
184
|
|
|
167
|
-
|
|
168
|
-
|
|
185
|
+
// --- Main input handler ---
|
|
186
|
+
node.on('input', function(msg) {
|
|
187
|
+
try {
|
|
188
|
+
// Rehydrate baseline/accumulated/lastCheck from context
|
|
189
|
+
baseline = node.context().get("baseline") || baseline;
|
|
169
190
|
accumulated = node.context().get("accumulated") || accumulated;
|
|
191
|
+
lastCheck = node.context().get("lastCheck") || lastCheck;
|
|
192
|
+
if (!(lastCheck instanceof Date)) lastCheck = new Date(lastCheck || Date.now());
|
|
170
193
|
|
|
171
|
-
//
|
|
172
|
-
if (
|
|
173
|
-
|
|
194
|
+
// Reset command: clear lastAppliedEditor so same editor values can be applied again on next Deploy
|
|
195
|
+
if (msg && msg.topic === "resetEditorApplied") {
|
|
196
|
+
node.context().set("lastAppliedEditor", { daily:0, weekly:0, monthly:0, yearly:0 });
|
|
197
|
+
node.log("Runtime: lastAppliedEditor cleared by reset message.");
|
|
198
|
+
return;
|
|
174
199
|
}
|
|
175
200
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
let dWeekly = toNum(msg.payload.weekly);
|
|
183
|
-
let dMonthly = toNum(msg.payload.monthly);
|
|
184
|
-
let dYearly = toNum(msg.payload.yearly);
|
|
201
|
+
// Runtime applyBaseline via injected JSON (immediate apply without Deploy)
|
|
202
|
+
if (msg && msg.topic === "applyBaseline" && msg.payload && typeof msg.payload === "object") {
|
|
203
|
+
const dDaily = toNum(msg.payload.daily);
|
|
204
|
+
const dWeekly = toNum(msg.payload.weekly);
|
|
205
|
+
const dMonthly = toNum(msg.payload.monthly);
|
|
206
|
+
const dYearly = toNum(msg.payload.yearly);
|
|
185
207
|
|
|
208
|
+
baseline = node.context().get("baseline") || baseline;
|
|
186
209
|
baseline.daily += dDaily;
|
|
187
210
|
baseline.weekly += dWeekly;
|
|
188
211
|
baseline.monthly += dMonthly;
|
|
189
212
|
baseline.yearly += dYearly;
|
|
190
|
-
|
|
191
213
|
node.context().set("baseline", baseline);
|
|
192
|
-
|
|
214
|
+
|
|
215
|
+
node.log(`Runtime applyBaseline message applied: daily ${dDaily}, weekly ${dWeekly}, monthly ${dMonthly}, yearly ${dYearly}`);
|
|
216
|
+
return;
|
|
193
217
|
}
|
|
194
218
|
|
|
195
|
-
//
|
|
196
|
-
|
|
219
|
+
// Otherwise treat payload as power reading (numeric)
|
|
220
|
+
const now = new Date();
|
|
221
|
+
const durationHours = (now - lastCheck) / (1000 * 3600);
|
|
222
|
+
|
|
223
|
+
const power = Number(msg.payload);
|
|
197
224
|
if (!isNaN(power) && durationHours > 0) {
|
|
198
|
-
|
|
199
|
-
|
|
225
|
+
const power_kW = (inputUnit === "W") ? power / 1000 : power;
|
|
226
|
+
const energyIncrement = power_kW * durationHours;
|
|
200
227
|
|
|
201
228
|
accumulated.daily += energyIncrement;
|
|
202
229
|
accumulated.weekly += energyIncrement;
|
|
@@ -206,14 +233,14 @@ module.exports = function(RED) {
|
|
|
206
233
|
node.log(`Power input processed: ${power} ${inputUnit} -> +${energyIncrement.toFixed(6)} kWh over ${durationHours.toFixed(6)} h`);
|
|
207
234
|
}
|
|
208
235
|
|
|
209
|
-
// Update timestamps and persist
|
|
236
|
+
// Update timestamps and persist context
|
|
210
237
|
lastCheck = now;
|
|
211
238
|
node.context().set("lastCheck", lastCheck);
|
|
212
239
|
node.context().set("accumulated", accumulated);
|
|
213
240
|
node.context().set("baseline", baseline);
|
|
214
241
|
|
|
215
242
|
// Build output payload
|
|
216
|
-
|
|
243
|
+
const payload = {
|
|
217
244
|
energyDaily: round2(baseline.daily + accumulated.daily),
|
|
218
245
|
energyWeekly: round2(baseline.weekly + accumulated.weekly),
|
|
219
246
|
energyMonthly: round2(baseline.monthly + accumulated.monthly),
|
|
@@ -225,15 +252,17 @@ module.exports = function(RED) {
|
|
|
225
252
|
currency: currencySymbol(currencyCode)
|
|
226
253
|
};
|
|
227
254
|
|
|
228
|
-
// Persist to file
|
|
255
|
+
// Persist to file (best-effort)
|
|
229
256
|
try {
|
|
230
257
|
const dir = path.dirname(filePath);
|
|
231
258
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
232
|
-
fs.writeFileSync(filePath, JSON.stringify(
|
|
259
|
+
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2));
|
|
233
260
|
} catch (err) {
|
|
234
261
|
node.error("Failed to write to file: " + err);
|
|
235
262
|
}
|
|
236
263
|
|
|
264
|
+
// Send output
|
|
265
|
+
msg.payload = payload;
|
|
237
266
|
node.send(msg);
|
|
238
267
|
} catch (err) {
|
|
239
268
|
node.error("Input handler error: " + err);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-energymeterplus",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "A custom Node-RED node that integrates power readings into energy totals with persistence and cost calculation.",
|
|
5
5
|
"author": "Arcfrankye",
|
|
6
6
|
"license": "MIT",
|