node-red-contrib-energymeterplus 0.3.4 → 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 CHANGED
@@ -1,224 +1,139 @@
1
- #### **EnergyMeterPlus Node Version 0.3.0**
1
+ ##### **EnergyMeterPlus** Node Version 1.0.1
2
+ @ arcfrankye 26-06-26
2
3
 
3
4
 
4
5
 
5
- ##### **Overview:**
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
- EnergyMeterPlus is a custom Node‑RED function node designed to convert instantaneous power readings (Watts) into accumulated energy values (kWh) over time. It integrates power based on elapsed time between samples, applies baseline corrections, calculates costs, and persists totals across restarts.
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
- ##### **Features:**
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
- * Converts power (W) into energy (kWh) using time integration.
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
- * Calculates costs based on a configurable unit cost.
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
- * Supports baseline corrections to start counters from existing values.
65
+  "energyWeekly": 32.18,
36
66
 
67
+  "energyMonthly": 128.74,
37
68
 
69
+  "energyYearly": 1024.56,
38
70
 
39
- * Handles rollover at day, week, month, and year boundaries.
71
+  "daily\\\\\\\_cost": 0.68,
40
72
 
73
+  "weekly\\\\\\\_cost": 4.83,
41
74
 
75
+  "monthly\\\\\\\_cost": 19.31,
42
76
 
43
- * Persists counters and timestamps in Node‑RED context and writes snapshots to file.
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
- ##### **Configuration:**
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
- Currency: Choose USD, EUR, or NGN (fixed values, not convertible)
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
- File Path: Path to store snapshots (default: /config/node\_red/solargen\_data.json).
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
-
@@ -1,67 +1,134 @@
1
+ /*
2
+ * energyMeterPlus Editor UX #arcfrankye - 27/06/2026
3
+ */
1
4
 
2
5
  <script type="text/javascript">
3
- RED.nodes.registerType('energyMeterPlus', {
4
- category: 'energy',
5
- color: '#f3a108fe', //-- Dutch Orange --
6
- defaults: {
7
- name: {value:""},
8
- baselineDaily: {value:0, required:false},
9
- baselineWeekly: {value:0, required:false},
10
- baselineMonthly: {value:0, required:false},
11
- baselineYearly: {value:0, required:false},
12
- unitCost: {value:0.15, required:true},
13
- currency: {value:"USD", required:true},
14
- filePath: {value:"/config/node_red/solargen_data.json", required:false},
15
- inputUnit: {value:"W", required:true}
16
- },
17
- inputs:1,
18
- outputs:1,
19
- icon: "sun.png",
20
- label: function() {
21
- return this.name || "energyMeterPlus";
22
- },
23
- oneditprepare: function() {
24
- // Nothing special here yet
25
- },
26
- oneditsave: function() {
27
- // Collect baseline values
28
- var daily = Number($("#node-input-baselineDaily").val()) || 0;
29
- var weekly = Number($("#node-input-baselineWeekly").val()) || 0;
30
- var monthly = Number($("#node-input-baselineMonthly").val()) || 0;
31
- var yearly = Number($("#node-input-baselineYearly").val()) || 0;
32
-
33
- var baselineMsg = {
34
- nodeId: this.id,
35
- payload: { daily, weekly, monthly, yearly }
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
- RED.comms.send("energyMeterPlus/applyBaseline", baselineMsg);
45
- RED.notify("✅ Baseline offset applied successfully", {type:"success", timeout:2000});
46
- console.log("Baseline offset applied:", baselineMsg.payload);
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
- // Clear fields after save
50
- $("#node-input-baselineDaily").val("");
51
- $("#node-input-baselineWeekly").val("");
52
- $("#node-input-baselineMonthly").val("");
53
- $("#node-input-baselineYearly").val("");
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
- <!-- Other settings -->
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
- <script type="text/x-red" data-help-name="energyMeterPlus">
109
- <p><b>Energy Meter Plus</b> tracks energy consumption and cost.</p>
110
- <p>Outputs <code>energyDaily</code>, <code>energyWeekly</code>, <code>energyMonthly</code>, and <code>energyYearly</code> values, plus cost calculations.</p>
111
- <p>You can set baseline offsets (daily, weekly, monthly, yearly) to adjust counters. These offsets are additive and applied on top of live increments.</p>
112
- <p>Rollover logic resets counters at midnight (daily), Sunday midnight (weekly), first day of month (monthly), and Dec 31 midnight (yearly, with archive).</p>
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>
@@ -1,168 +1,279 @@
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
- var node = this;
8
-
9
- let unitCost = Number(config.unitCost) || 0;
10
- let filePath = config.filePath || "/config/node_red/solargen_data.json";
11
- let inputUnit = config.inputUnit || "W";
12
- let currencyCode = config.currency || "USD";
13
-
14
- // --- Editor-config delta apply (run on every deploy)
15
- function toNum(v) { const n = Number(v); return isNaN(n) ? 0 : n; }
16
-
17
- // read stored "last applied editor values" (so we can compute deltas)
18
- let lastApplied = node.context().get("lastAppliedEditor") || { daily:0, weekly:0, monthly:0, yearly:0 };
19
-
20
- // current editor values (always read from config)
21
- let editor = {
22
- daily: toNum(config.baselineDaily),
23
- weekly: toNum(config.baselineWeekly),
24
- monthly: toNum(config.baselineMonthly),
25
- yearly: toNum(config.baselineYearly)
26
- };
27
-
28
- // compute delta = editor - lastApplied
29
- let delta = {
30
- daily: editor.daily - (toNum(lastApplied.daily) || 0),
31
- weekly: editor.weekly - (toNum(lastApplied.weekly) || 0),
32
- monthly: editor.monthly - (toNum(lastApplied.monthly) || 0),
33
- yearly: editor.yearly - (toNum(lastApplied.yearly) || 0)
34
- };
35
-
36
- // ensure baseline exists
37
- let baseline = node.context().get("baseline") || { daily:0, weekly:0, monthly:0, yearly:0 };
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";
38
23
 
39
- // apply delta (this adds positive or negative changes)
40
- baseline.daily += delta.daily;
41
- baseline.weekly += delta.weekly;
42
- baseline.monthly += delta.monthly;
43
- baseline.yearly += delta.yearly;
24
+ // Helper: safe number parse
25
+ function toNum(v) {
26
+ const n = Number(v);
27
+ return isNaN(n) ? 0 : n;
28
+ }
29
+
30
+ // Helper: rounding
31
+ function round2(val) {
32
+ if (val === null || val === undefined || isNaN(val)) return 0;
33
+ return Number(val.toFixed(2));
34
+ }
35
+
36
+ // Helper: currency symbol
37
+ function currencySymbol(code) {
38
+ switch (code) {
39
+ case "USD": return "$";
40
+ case "EUR": return "€";
41
+ case "NGN": return "₦";
42
+ default: return code;
43
+ }
44
+ }
44
45
 
45
- // persist baseline and remember the editor values we just applied
46
+ // Ensure baseline exists in context
47
+ let baseline = node.context().get("baseline") || { daily:0, weekly:0, monthly:0, yearly:0 };
46
48
  node.context().set("baseline", baseline);
47
- node.context().set("lastAppliedEditor", editor);
48
- // -------------------------------------------------------------------------------
49
49
 
50
- // Accumulated values
50
+ // Ensure accumulated exists in context
51
51
  let accumulated = node.context().get("accumulated") || { daily:0, weekly:0, monthly:0, yearly:0 };
52
+ node.context().set("accumulated", accumulated);
52
53
 
53
- // Last check timestamp
54
+ // Ensure lastCheck exists and is a Date
54
55
  let lastCheck = node.context().get("lastCheck");
55
56
  if (!(lastCheck instanceof Date)) {
56
57
  lastCheck = new Date(lastCheck || Date.now());
58
+ node.context().set("lastCheck", lastCheck);
57
59
  }
58
60
 
59
- // 1. Input handler: baseline updates + power increments
60
- node.on('input', function(msg) {
61
- let now = new Date();
62
- let durationHours = (now - lastCheck) / (1000 * 3600);
63
-
64
- // Handle baseline updates via message (runtime applyBaseline)
65
- if (msg.topic === "applyBaseline" && msg.payload) {
66
- baseline.daily += Number(msg.payload.daily) || 0;
67
- baseline.weekly += Number(msg.payload.weekly) || 0;
68
- baseline.monthly += Number(msg.payload.monthly) || 0;
69
- baseline.yearly += Number(msg.payload.yearly) || 0;
70
- node.context().set("baseline", baseline);
71
- }
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);
72
64
 
73
- // Handle power input
74
- let power = Number(msg.payload);
75
- if (!isNaN(power) && durationHours > 0) {
76
- let power_kW = (inputUnit === "W") ? power / 1000 : power;
77
- let energyIncrement = power_kW * durationHours;
65
+ // --- Apply editor baseline on deploy if editor flagged applied === true ---
66
+ try {
67
+ const editor = {
68
+ daily: toNum(config.baselineDaily),
69
+ weekly: toNum(config.baselineWeekly),
70
+ monthly: toNum(config.baselineMonthly),
71
+ yearly: toNum(config.baselineYearly)
72
+ };
78
73
 
79
- accumulated.daily += energyIncrement;
80
- accumulated.weekly += energyIncrement;
81
- accumulated.monthly += energyIncrement;
82
- accumulated.yearly += energyIncrement;
83
- }
74
+ const appliedFlag = Boolean(config.applied);
84
75
 
85
- lastCheck = now;
86
- node.context().set("lastCheck", lastCheck);
87
- node.context().set("accumulated", accumulated);
88
-
89
- // 3. Output payload
90
- msg.payload = {
91
- energyDaily: round2(baseline.daily + accumulated.daily),
92
- energyWeekly: round2(baseline.weekly + accumulated.weekly),
93
- energyMonthly: round2(baseline.monthly + accumulated.monthly),
94
- energyYearly: round2(baseline.yearly + accumulated.yearly),
95
- daily_cost: round2((baseline.daily + accumulated.daily) * unitCost),
96
- weekly_cost: round2((baseline.weekly + accumulated.weekly) * unitCost),
97
- monthly_cost: round2((baseline.monthly + accumulated.monthly) * unitCost),
98
- yearly_cost: round2((baseline.yearly + accumulated.yearly) * unitCost),
99
- currency: currencySymbol(currencyCode)
100
- };
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
+ };
101
84
 
102
- // 4. Persist to file
103
- const dir = path.dirname(filePath);
104
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
105
- try { fs.writeFileSync(filePath, JSON.stringify(msg.payload, null, 2)); }
106
- catch (err) { node.error("Failed to write to file: " + err); }
85
+ if (delta.daily || delta.weekly || delta.monthly || delta.yearly) {
86
+ baseline = node.context().get("baseline") || { daily:0, weekly:0, monthly:0, yearly:0 };
107
87
 
108
- node.send(msg);
109
- });
88
+ baseline.daily += delta.daily;
89
+ baseline.weekly += delta.weekly;
90
+ baseline.monthly += delta.monthly;
91
+ baseline.yearly += delta.yearly;
110
92
 
111
- // 5. Rollover logic
112
- function checkRollover() {
113
- let now = new Date();
93
+ node.context().set("baseline", baseline);
94
+ node.context().set("lastAppliedEditor", editor);
114
95
 
115
- if (!(lastCheck instanceof Date)) {
116
- lastCheck = new Date(lastCheck || Date.now());
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).");
99
+ }
100
+ } else {
101
+ node.log("Editor baseline not flagged applied on deploy; no editor delta applied.");
117
102
  }
103
+ } catch (err) {
104
+ node.error("Error applying editor baseline on deploy: " + err);
105
+ }
118
106
 
119
- if (now.getDate() !== lastCheck.getDate()) {
120
- accumulated.daily = 0; baseline.daily = 0;
121
- }
122
- if (now.getDay() === 1 && lastCheck.getDay() !== 1) {
123
- accumulated.weekly = 0; baseline.weekly = 0;
124
- }
125
- if (now.getMonth() !== lastCheck.getMonth()) {
126
- accumulated.monthly = 0; baseline.monthly = 0;
127
- }
128
- if (now.getFullYear() !== lastCheck.getFullYear()) {
129
- archiveYearly(baseline.yearly + accumulated.yearly, lastCheck.getFullYear());
130
- accumulated.yearly = 0; baseline.yearly = 0;
131
- }
107
+ // --- Rollover logic ---
108
+ function checkRollover() {
109
+ try {
110
+ const now = new Date();
132
111
 
133
- lastCheck = now;
134
- node.context().set("lastCheck", lastCheck);
135
- node.context().set("accumulated", accumulated);
136
- node.context().set("baseline", baseline);
137
- }
138
- setInterval(checkRollover, 60000);
112
+ if (!(lastCheck instanceof Date)) {
113
+ lastCheck = new Date(lastCheck || Date.now());
114
+ }
139
115
 
140
- // 6. Helpers
141
- function currencySymbol(code) {
142
- switch (code) {
143
- case "USD": return "$";
144
- case "EUR": return "€";
145
- case "NGN": return "₦";
146
- default: return code;
116
+ // daily rollover (date change)
117
+ if (now.getDate() !== lastCheck.getDate() || now.toDateString() !== lastCheck.toDateString()) {
118
+ accumulated.daily = 0;
119
+ baseline = node.context().get("baseline") || baseline;
120
+ baseline.daily = 0;
121
+ node.context().set("baseline", baseline);
122
+ node.log("Daily rollover executed.");
123
+ }
124
+
125
+ // weekly rollover (assume week starts Monday)
126
+ // If lastCheck was not Monday and now is Monday, reset weekly
127
+ if (now.getDay() === 1 && lastCheck.getDay() !== 1) {
128
+ accumulated.weekly = 0;
129
+ baseline = node.context().get("baseline") || baseline;
130
+ baseline.weekly = 0;
131
+ node.context().set("baseline", baseline);
132
+ node.log("Weekly rollover executed.");
133
+ }
134
+
135
+ // monthly rollover
136
+ if (now.getMonth() !== lastCheck.getMonth()) {
137
+ accumulated.monthly = 0;
138
+ baseline = node.context().get("baseline") || baseline;
139
+ baseline.monthly = 0;
140
+ node.context().set("baseline", baseline);
141
+ node.log("Monthly rollover executed.");
142
+ }
143
+
144
+ // yearly rollover
145
+ if (now.getFullYear() !== lastCheck.getFullYear()) {
146
+ baseline = node.context().get("baseline") || baseline;
147
+ archiveYearly(baseline.yearly + accumulated.yearly, lastCheck.getFullYear());
148
+ accumulated.yearly = 0;
149
+ baseline.yearly = 0;
150
+ node.context().set("baseline", baseline);
151
+ node.log("Yearly rollover executed and archived.");
152
+ }
153
+
154
+ lastCheck = now;
155
+ node.context().set("lastCheck", lastCheck);
156
+ node.context().set("accumulated", accumulated);
157
+ } catch (err) {
158
+ node.error("Rollover error: " + err);
147
159
  }
148
160
  }
149
- function round2(val) {
150
- if (val === null || val === undefined || isNaN(val)) return 0;
151
- return Number(val.toFixed(2));
152
- }
161
+
162
+ const rolloverInterval = setInterval(checkRollover, 60000);
163
+
164
+ // --- Archive helper ---
153
165
  function archiveYearly(total, year) {
154
166
  try {
155
167
  const archivePath = path.join(path.dirname(filePath), "yearly_archive.json");
156
168
  let archive = [];
157
169
  if (fs.existsSync(archivePath)) {
158
- archive = JSON.parse(fs.readFileSync(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
+ }
159
176
  }
160
177
  archive.push({ year, total, timestamp: new Date().toISOString() });
161
178
  fs.writeFileSync(archivePath, JSON.stringify(archive, null, 2));
179
+ node.log(`Archived yearly total for ${year}: ${total}`);
162
180
  } catch (err) {
163
181
  node.error("Failed to archive yearly total: " + err);
164
182
  }
165
183
  }
184
+
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;
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());
193
+
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;
199
+ }
200
+
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);
207
+
208
+ baseline = node.context().get("baseline") || baseline;
209
+ baseline.daily += dDaily;
210
+ baseline.weekly += dWeekly;
211
+ baseline.monthly += dMonthly;
212
+ baseline.yearly += dYearly;
213
+ node.context().set("baseline", baseline);
214
+
215
+ node.log(`Runtime applyBaseline message applied: daily ${dDaily}, weekly ${dWeekly}, monthly ${dMonthly}, yearly ${dYearly}`);
216
+ return;
217
+ }
218
+
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);
224
+ if (!isNaN(power) && durationHours > 0) {
225
+ const power_kW = (inputUnit === "W") ? power / 1000 : power;
226
+ const energyIncrement = power_kW * durationHours;
227
+
228
+ accumulated.daily += energyIncrement;
229
+ accumulated.weekly += energyIncrement;
230
+ accumulated.monthly += energyIncrement;
231
+ accumulated.yearly += energyIncrement;
232
+
233
+ node.log(`Power input processed: ${power} ${inputUnit} -> +${energyIncrement.toFixed(6)} kWh over ${durationHours.toFixed(6)} h`);
234
+ }
235
+
236
+ // Update timestamps and persist context
237
+ lastCheck = now;
238
+ node.context().set("lastCheck", lastCheck);
239
+ node.context().set("accumulated", accumulated);
240
+ node.context().set("baseline", baseline);
241
+
242
+ // Build output payload
243
+ const payload = {
244
+ energyDaily: round2(baseline.daily + accumulated.daily),
245
+ energyWeekly: round2(baseline.weekly + accumulated.weekly),
246
+ energyMonthly: round2(baseline.monthly + accumulated.monthly),
247
+ energyYearly: round2(baseline.yearly + accumulated.yearly),
248
+ daily_cost: round2((baseline.daily + accumulated.daily) * unitCost),
249
+ weekly_cost: round2((baseline.weekly + accumulated.weekly) * unitCost),
250
+ monthly_cost: round2((baseline.monthly + accumulated.monthly) * unitCost),
251
+ yearly_cost: round2((baseline.yearly + accumulated.yearly) * unitCost),
252
+ currency: currencySymbol(currencyCode)
253
+ };
254
+
255
+ // Persist to file (best-effort)
256
+ try {
257
+ const dir = path.dirname(filePath);
258
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
259
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2));
260
+ } catch (err) {
261
+ node.error("Failed to write to file: " + err);
262
+ }
263
+
264
+ // Send output
265
+ msg.payload = payload;
266
+ node.send(msg);
267
+ } catch (err) {
268
+ node.error("Input handler error: " + err);
269
+ }
270
+ });
271
+
272
+ // Clean up on close
273
+ node.on('close', function(removed, done) {
274
+ if (rolloverInterval) clearInterval(rolloverInterval);
275
+ done();
276
+ });
166
277
  }
167
278
 
168
279
  RED.nodes.registerType("energyMeterPlus", EnergyMeterPlus, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-energymeterplus",
3
- "version": "0.3.4",
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",