node-red-contrib-energymeterplus 0.3.5 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,224 +1,226 @@
1
- #### **EnergyMeterPlus Node Version 0.3.0**
1
+ ##### **EnergyMeterPlus** Node Version 1.0.1
2
+ @ arcfrankye 26-06-26
3
+ {The first stable release is here at last!}
2
4
 
3
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.
4
8
 
5
- ##### **Overview:**
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.
6
10
 
7
11
 
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.
8
23
 
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.
10
24
 
25
+ ###### Configuration Options
11
26
 
27
+ **Unit Cost**:
28
+ * Cost per kWh (e.g., `0.15` for $0.15/kWh).
12
29
 
30
+ **Currency**:
31
+ * Choose USD, EUR, or NGN. The node uses a symbol for display, no currency conversion.
13
32
 
33
+ **File Path**:
34
+ * Path to write snapshots (default: `/config/node\\\\\\\_red/solargen\\\\\\\_data.json`). Directories are created automatically.
14
35
 
15
- ##### **Features:**
36
+ **Input Unit**:
37
+ * "W" (Watts) or "kW" (kilowatts). If using Watts, the node converts to kW internally.
16
38
 
17
39
 
40
+ **Baseline Offsets**
41
+ * Baseline Daily, Weekly, Monthly, Yearly: enter offsets in the editor.
18
42
 
19
- * Converts power (W) into energy (kWh) using time integration.
20
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.
21
46
 
22
47
 
23
- * Tracks daily, weekly, monthly, and yearly energy totals.
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.
24
50
 
25
51
 
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).
26
54
 
27
- * Calculates costs based on a configurable unit cost.
28
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**.
29
58
 
30
59
 
31
- * Unit cost configurable in 3 different currencies
60
+ ###### Output Payload
61
+ The node outputs a `msg.payload` object with rounded values (two decimals):
32
62
 
63
+  "energyDaily": 4.53,
33
64
 
65
+  "energyWeekly": 32.18,
34
66
 
35
- * Supports baseline corrections to start counters from existing values.
67
+  "energyMonthly": 128.74,
36
68
 
69
+  "energyYearly": 1024.56,
37
70
 
71
+  "daily\\\\\\\_cost": 0.68,
38
72
 
39
- * Handles rollover at day, week, month, and year boundaries.
73
+  "weekly\\\\\\\_cost": 4.83,
40
74
 
75
+  "monthly\\\\\\\_cost": 19.31,
41
76
 
77
+  "yearly\\\\\\\_cost": 153.68,
42
78
 
43
- * Persists counters and timestamps in Node‑RED context and writes snapshots to file.
79
+  "currency": "?"
44
80
 
81
+ Note: field names are `energyDaily`, `energyWeekly`, `energyMonthly`, `energyYearly` in the runtime implementation.
45
82
 
46
83
 
47
- * Outputs clean values rounded to two decimal places for dashboards.
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`.
48
89
 
49
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.
50
95
 
51
96
 
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.
52
102
 
53
- ##### **Configuration:**
54
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.
55
109
 
56
110
 
57
- Unit Cost: Cost per kWh (e.g., 0.15 for $0.15/kWh).
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.
58
114
 
59
115
 
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.
60
119
 
61
- Currency: Choose USD, EUR, or NGN (fixed values, not convertible)
120
+ **v0.2.5**
121
+ * Fixed internal counter bug.
62
122
 
123
+ **v0.2.4**
124
+ * Fixed incremental counter update bug. Added payload validation. Baselines captured once and survive UI clearing.
63
125
 
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.
64
128
 
65
- File Path: Path to store snapshots (default: /config/node\_red/solargen\_data.json).
129
+ **v0.0.3**
130
+ * Initial release: integrates power to energy, cost calc, rollovers, persistence, and basic baseline support.
66
131
 
67
132
 
133
+ ###### Quick Start Demo Flow
134
+ To help you get started with **EnergyMeterPlus**, here is a small demo flow you can import directly into Node RED. It shows how to use the new Apply and Reset features, as well as runtime messages.
68
135
 
69
- Input Unit: "W" for Watts (default) or "kW" if your source already provides kilowatts.
136
+ **Steps**
137
+ Open Node RED.
70
138
 
139
+ Click the menu (top right) → Import → Clipboard.
71
140
 
141
+ Paste the JSON below into the box.
72
142
 
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
-
75
-
76
-
77
-
78
-
79
- ###### **Output:**
80
-
81
-
82
-
83
- The node outputs a payload object:
143
+ Click Import.
84
144
 
145
+ You should see two Inject nodes wired into **EnergyMeterPlus** and a "Energy Output" debug node.
85
146
 
147
+ Click the Inject buttons to test Apply/Reset and watch the payloads in the Debug sidebar.
86
148
 
149
+ **Demo Flow JSON**
87
150
  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
-
151
+ [
152
+ {
153
+ "id": "inject-apply",
154
+ "type": "inject",
155
+ "z": "flow1",
156
+ "name": "Apply Baseline (2 kWh daily)",
157
+ "props": [
158
+ { "p": "payload" },
159
+ { "p": "topic", "vt": "str" }
160
+ ],
161
+ "payload": "{\"daily\":2,\"weekly\":0,\"monthly\":0,\"yearly\":0}",
162
+ "payloadType": "json",
163
+ "topic": "applyBaseline",
164
+ "x": 180,
165
+ "y": 120,
166
+ "wires": [["energyMeterPlus"]]
167
+ },
168
+ {
169
+ "id": "inject-reset",
170
+ "type": "inject",
171
+ "z": "flow1",
172
+ "name": "Reset Editor Applied",
173
+ "props": [
174
+ { "p": "topic", "vt": "str" }
175
+ ],
176
+ "topic": "resetEditorApplied",
177
+ "x": 170,
178
+ "y": 180,
179
+ "wires": [["energyMeterPlus"]]
180
+ },
181
+ {
182
+ "id": "energyMeterPlus",
183
+ "type": "energyMeterPlus",
184
+ "z": "flow1",
185
+ "name": "EnergyMeterPlus",
186
+ "unitCost": "0.15",
187
+ "currency": "USD",
188
+ "filePath": "/config/node_red/solargen_data.json",
189
+ "inputUnit": "W",
190
+ "baselineDaily": "0",
191
+ "baselineWeekly": "0",
192
+ "baselineMonthly": "0",
193
+ "baselineYearly": "0",
194
+ "applied": false,
195
+ "x": 420,
196
+ "y": 150,
197
+ "wires": [["debug-output"]]
198
+ },
199
+ {
200
+ "id": "debug-output",
201
+ "type": "debug",
202
+ "z": "flow1",
203
+ "name": "Energy Output",
204
+ "active": true,
205
+ "tosidebar": true,
206
+ "console": false,
207
+ "tostatus": false,
208
+ "complete": "payload",
209
+ "targetType": "msg",
210
+ "x": 650,
211
+ "y": 150,
212
+ "wires": []
213
+ }
214
+ ]
215
+ **What it does**
216
+ Apply Baseline (2 kWh daily) sends a runtime applyBaseline message that adds 2 kWh to the daily baseline immediately.
217
+
218
+ Reset Editor Applied → clears the remembered editor baseline so the same values can be applied again on next Deploy.
219
+
220
+ Debug Outputshows the updated payload with energy totals and costs.
217
221
 
218
222
 
219
- ###### **v3.0**
220
223
 
221
224
 
222
225
 
223
- **Major overhaul of UI and internal logic programming to fix bugs in counters and baseline corrections**
224
226
 
@@ -1,67 +1,143 @@
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
+ }
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;
47
65
  }
48
66
 
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("");
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
+ });
83
+
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
+ <!-- Tool tip -->
126
+ <script type="text/x-red" data-help-name="energyMeterPlus">
127
+ <p><b>EnergyMeterPlus</b>: Converts instantaneous power readings into accumulated energy totals and cost, with robust baseline handling with archiving.</p>
128
+ <p><b>EnergyMeterPlus</b>: node integrates instantaneous power readings into daily, weekly, monthly, and yearly energy totals, with rollover logic that can be aligned to your reporting cycle.</p>
129
+ <p> Baseline offsets are applied once using the editor’s Apply button, preventing accidental double‑application, while the Reset button allows intentional re‑application when needed.</p>
130
+ <p>Totals and costs are persisted in context and written to file, with optional archiving of yearly data for long‑term analysis. Outputs are rounded for clean dashboard display.</p>
131
+ </script>
132
+
133
+
134
+ <!-- Template -->
58
135
  <script type="text/x-red" data-template-name="energyMeterPlus">
59
136
  <div class="form-row">
60
137
  <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
61
138
  <input type="text" id="node-input-name">
62
139
  </div>
63
140
 
64
- <!-- Baseline offsets -->
65
141
  <div class="form-row">
66
142
  <label for="node-input-baselineDaily"><i class="fa fa-calendar-day"></i> Baseline Daily Offset</label>
67
143
  <input type="number" id="node-input-baselineDaily" placeholder="Daily offset">
@@ -79,7 +155,19 @@
79
155
  <input type="number" id="node-input-baselineYearly" placeholder="Yearly offset">
80
156
  </div>
81
157
 
82
- <!-- Other settings -->
158
+ <!-- Hidden applied flag persisted on save -->
159
+ <input type="hidden" id="node-input-applied" value="false">
160
+
161
+ <!-- Apply and Reset controls -->
162
+ <div class="form-row">
163
+ <button id="energy-apply-btn" class="editor-button">Apply</button>
164
+ <button id="energy-reset-applied" class="editor-button">Reset</button>
165
+ <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>
166
+ <div class="help">Click Apply to mark these offsets as used. Click Reset to allow reapply.</div>
167
+ </div>
168
+
169
+ <hr/>
170
+
83
171
  <div class="form-row">
84
172
  <label for="node-input-unitCost"><i class="fa fa-money"></i> Cost per Unit</label>
85
173
  <input type="number" id="node-input-unitCost" step="0.01">
@@ -103,11 +191,10 @@
103
191
  <option value="W">W</option>
104
192
  </select>
105
193
  </div>
106
- </script>
107
194
 
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>
195
+ <style>
196
+ /* subtle greyed-out look for disabled baseline inputs */
197
+ .nr-disabled { background:#f3f3f3 !important; color:#666 !important; }
198
+ .editor-button { margin-right:6px; }
199
+ </style>
113
200
  </script>
@@ -1,19 +1,39 @@
1
+ /**
2
+ * energyMeterPlus runtime #arcfrankye - 27/06/2026
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;
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
- // Config
10
- let unitCost = Number(config.unitCost) || 0;
11
- let filePath = config.filePath || "/config/node_red/solargen_data.json";
12
- let inputUnit = config.inputUnit || "W";
13
- let currencyCode = config.currency || "USD";
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
- // Helpers
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
- function round2(val) {
26
- if (val === null || val === undefined || isNaN(val)) return 0;
27
- return Number(val.toFixed(2));
28
- }
29
- function archiveYearly(total, year) {
30
- try {
31
- const archivePath = path.join(path.dirname(filePath), "yearly_archive.json");
32
- let archive = [];
33
- if (fs.existsSync(archivePath)) {
34
- archive = JSON.parse(fs.readFileSync(archivePath));
35
- }
36
- archive.push({ year, total, timestamp: new Date().toISOString() });
37
- fs.writeFileSync(archivePath, JSON.stringify(archive, null, 2));
38
- node.log(`Archived yearly total for ${year}: ${total}`);
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
- // --- Editor-config delta apply (run on every deploy)
45
- try {
46
- let lastApplied = node.context().get("lastAppliedEditor") || { daily:0, weekly:0, monthly:0, yearly:0 };
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
- let editor = {
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
- let delta = {
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
- // Ensure baseline exists
63
- let baseline = node.context().get("baseline") || { daily:0, weekly:0, monthly:0, yearly:0 };
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
- // Apply delta only if non-zero (keeps logs clean)
66
- if (delta.daily || delta.weekly || delta.monthly || delta.yearly) {
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
- node.context().set("baseline", baseline);
73
- node.context().set("lastAppliedEditor", editor);
88
+ baseline.daily += delta.daily;
89
+ baseline.weekly += delta.weekly;
90
+ baseline.monthly += delta.monthly;
91
+ baseline.yearly += delta.yearly;
74
92
 
75
- node.log(`Editor baseline applied (delta): daily ${delta.daily}, weekly ${delta.weekly}, monthly ${delta.monthly}, yearly ${delta.yearly}`);
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
- node.log("Editor baseline: no change to apply on deploy.");
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 delta: " + err);
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 interval handle
98
- let rolloverInterval = null;
99
-
100
- // Rollover logic
107
+ // --- Rollover logic ---
101
108
  function checkRollover() {
102
109
  try {
103
- let now = new Date();
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
- let baseline = node.context().get("baseline") || { daily:0, weekly:0, monthly:0, yearly:0 };
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
- let baseline = node.context().get("baseline") || { daily:0, weekly:0, monthly:0, yearly:0 };
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
- let baseline = node.context().get("baseline") || { daily:0, weekly:0, monthly:0, yearly:0 };
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
- let baseline = node.context().get("baseline") || { daily:0, weekly:0, monthly:0, yearly:0 };
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
- // Start rollover timer
155
- rolloverInterval = setInterval(checkRollover, 60000);
162
+ const rolloverInterval = setInterval(checkRollover, 60000);
156
163
 
157
- // Main input handler
158
- node.on('input', function(msg) {
164
+ // --- Archive helper ---
165
+ function archiveYearly(total, year) {
159
166
  try {
160
- // Allow manual reset of lastAppliedEditor via message
161
- if (msg.topic === "resetEditorApplied") {
162
- node.context().set("lastAppliedEditor", { daily:0, weekly:0, monthly:0, yearly:0 });
163
- node.log("lastAppliedEditor cleared by message.");
164
- return;
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
- // Rehydrate baseline & accumulated from context (in case other code changed them)
168
- let baseline = node.context().get("baseline") || { daily:0, weekly:0, monthly:0, yearly:0 };
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
- // Ensure lastCheck is a Date
172
- if (!(lastCheck instanceof Date)) {
173
- lastCheck = new Date(lastCheck || Date.now());
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
- let now = new Date();
177
- let durationHours = (now - lastCheck) / (1000 * 3600);
178
-
179
- // Runtime applyBaseline message (adds values immediately)
180
- if (msg.topic === "applyBaseline" && msg.payload && typeof msg.payload === "object") {
181
- let dDaily = toNum(msg.payload.daily);
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
- node.log(`Runtime applyBaseline: daily ${dDaily}, weekly ${dWeekly}, monthly ${dMonthly}, yearly ${dYearly}`);
214
+
215
+ node.log(`Runtime applyBaseline message applied: daily ${dDaily}, weekly ${dWeekly}, monthly ${dMonthly}, yearly ${dYearly}`);
216
+ return;
193
217
  }
194
218
 
195
- // Power input handling (numeric payload)
196
- let power = Number(msg.payload);
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
- let power_kW = (inputUnit === "W") ? power / 1000 : power;
199
- let energyIncrement = power_kW * durationHours;
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
- msg.payload = {
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(msg.payload, null, 2));
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.5",
3
+ "version": "1.0.1",
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",