From 9b5e4c6039c92fa794e943926ea316c14a07c9ef Mon Sep 17 00:00:00 2001 From: joma999 Date: Wed, 19 Aug 2026 15:10:36 +0200 Subject: [PATCH 1/4] fix(dynamic): apply sub-strategy changes immediately Listen for changes to the cheap, regular, and expensive period sub-strategies so Dynamic recalculates the active strategy without requiring a stop and restart. --- node-red/02 strategy-dynamic-2.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/node-red/02 strategy-dynamic-2.json b/node-red/02 strategy-dynamic-2.json index 2ed2161..9e9ce67 100644 --- a/node-red/02 strategy-dynamic-2.json +++ b/node-red/02 strategy-dynamic-2.json @@ -932,6 +932,9 @@ "input_number.house_battery_strategy_dynamic_expensive_hrs", "input_select.house_battery_strategy_dynamic_data_source", "input_number.house_battery_strategy_dynamic_threshold_delta", + "input_select.house_battery_strategy_dynamic_default", + "input_select.house_battery_strategy_dynamic_cheapest", + "input_select.house_battery_strategy_dynamic_expensive", "input_select.house_battery_strategy" ], "substring": [], @@ -2070,4 +2073,4 @@ "node-red-contrib-home-assistant-websocket": "0.80.3" } } -] \ No newline at end of file +] From 21720b514e82fe38eff951774d8dd21c6a671e93 Mon Sep 17 00:00:00 2001 From: joma999 Date: Wed, 19 Aug 2026 15:18:09 +0200 Subject: [PATCH 2/4] chore: untrack Finder metadata --- .DS_Store | Bin 6148 -> 0 bytes home assistant/.DS_Store | Bin 6148 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store delete mode 100644 home assistant/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 689fa65a68e6d679245cb828e19b9c04bd439204..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}(1u5S~p!*$B1Dp;R1K;u_RIB}&zcmBI^PR1SbryN-&5@uJvi4pAf@;e~jV zK2N{d-Bci2j)hh;*6g=GGwa=NtsM^$so^x+Ch8H9hBCIgD1IY6&iX)Fp{D~>ZVp2U zRd{l!$iKhDPU>kjOeQDXI-9_Kbw|Cy}_05O9cZ)?=uW!EncsRN!E=zmuX;%0GoxBXVgd0>I z-CmKG+<`S_Az8WnsI<<1ZIfTquSuhvE&W6Z9Ld0Hc77NAy7CN5Ih_g)s0} G8Tbj)V_%j4 diff --git a/home assistant/.DS_Store b/home assistant/.DS_Store deleted file mode 100644 index 8c1d6d6615afb76919813ad015c0434f8bdb3985..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKOHRWu5FM8;D!yH^3`gh~8IpbDr0 zzfA#NyT`PkB~`Sh_WGUE9a`(Wo)ww31s3@3z~X)~o!PQ#G2A(DY11?-Y=-#J;NtD- zIe9tno1?V(w{xn8&i^_I2wfR4b+bU z>Px)y5*CLj-4x1sjiU z>p6fnbdnkJwmXKRNXpS2PE7S6_T mjYpS)LXKls;iLEz?hJhqSAel#;}I<|{Sa_6XrT)Hr~>bHo{Db( From a28a8bb8c5fbb09cde2d7467e510ed9939ed5b33 Mon Sep 17 00:00:00 2001 From: joma999 Date: Wed, 19 Aug 2026 15:32:32 +0200 Subject: [PATCH 3/4] feat(ev): extend trigger strategies and protect charge headroom Add Disabled, Charge PV, and Charge EV trigger strategies with a Charge capacity warning. Exclude battery charging power from Charge import peak detection so regulated charging uses the available import headroom. --- home assistant/dashboard.yaml | 14 ++++++++++++-- home assistant/packages/house_battery_control.yaml | 3 +++ node-red/01 start-flow.json | 4 ++-- node-red/02 strategy-partials.json | 2 +- node-red/all-flows-in-one-file.json | 6 +++--- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/home assistant/dashboard.yaml b/home assistant/dashboard.yaml index 2cdd311..f1d6a2a 100644 --- a/home assistant/dashboard.yaml +++ b/home assistant/dashboard.yaml @@ -1870,12 +1870,22 @@ views: secondary_info: none - entity: input_select.house_battery_strategy_ev_stop_trigger_strategy name: Trigger strategy + - type: markdown + visibility: + - condition: state + entity: input_select.house_battery_strategy_ev_stop_trigger_strategy + state: Charge + content: >- + The Charge trigger strategy + requires sufficient grid capacity and/or EV charger load balancing. + Otherwise EV and battery charging can overload the grid connection. - type: markdown content: >- Enter the `entity_id` of an `input_boolean` or `on/off template_sensor` in the field above. Choose whether an - active trigger should fully stop the batteries or keep them in - standby for peak shaving. + active trigger should fully stop the batteries, keep them in + standby for peak shaving, or charge. Select `Disabled` to retain + the configured sensor without changing the active battery strategy. text_only: true - type: tile grid_options: diff --git a/home assistant/packages/house_battery_control.yaml b/home assistant/packages/house_battery_control.yaml index 479faec..080a232 100644 --- a/home assistant/packages/house_battery_control.yaml +++ b/home assistant/packages/house_battery_control.yaml @@ -671,8 +671,11 @@ input_select: house_battery_strategy_ev_stop_trigger_strategy: name: EV Stop Trigger Strategy options: + - Disabled # Keep the configured trigger sensor without overriding the active battery strategy - Full stop # Stop (dis)charging and disengage grid relay - Standby / peak shave # Standby, peak shave only. Requires power limits to be set! + - Charge PV # Charge only when surplus PV is available + - Charge # Charge from the grid using the configured Charge strategy icon: mdi:power-plug-off # Available sub-strategies for timed / smart diff --git a/node-red/01 start-flow.json b/node-red/01 start-flow.json index d810a1d..9ee51ee 100644 --- a/node-red/01 start-flow.json +++ b/node-red/01 start-flow.json @@ -556,7 +556,7 @@ "z": "d606df74d35deb5a", "g": "2f55254a75de7eac", "name": "Strategy selection", - "func": "// Logger\nconst logger = global.get(\"logger\");\n\n// INFLOW\nlet strategy = flow.get(\"house_battery_strategy\");\n\n// Configure msg.strategy object\nRED.util.setMessageProperty(msg,\"strategy.selected\",strategy,true); // by user\nRED.util.setMessageProperty(msg,\"strategy.trace\",[],true); // init trace: which flows have been executed\n\n// stop on error\nif(msg.batteries === undefined) {\n logger(this, \"Battery configuration undefined\", \"error\");\n return null;\n}\nif(strategy === undefined) {\n logger(this, \"Battery strategy undefined\", \"error\");\n return null;\n}\n\n// Default | select target flow based on the input_select `house_battery_strategy`\nmsg.target = `${strategy}`;\n\n// EV stop trigger overrules selected strategies, except a manual Full stop.\nconst stopTrigger = RED.util.getMessageProperty(msg,\"full_stop_trigger\");\nconst isStopTriggerActive = stopTrigger == \"on\" || stopTrigger === true || stopTrigger === 1;\nif(isStopTriggerActive && strategy !== \"Full stop\") {\n const stopTriggerStrategy = RED.util.getMessageProperty(msg,\"ev_stop_trigger_strategy\") || \"Full stop\";\n msg.target = stopTriggerStrategy;\n // Explain\n logger(this, `**${stopTriggerStrategy}** strategy selected, due to EV stop trigger: '${stopTrigger}'`);\n}\n\n// add execution marker\nRED.util.setMessageProperty(msg,\"strategy.execution_start\",Date.now()); // when do we start executing the selected strategy\n\n// OUTPUT\nnode.status({fill:\"grey\",shape:\"dot\",text:msg.target});\nreturn msg;", + "func": "// Logger\nconst logger = global.get(\"logger\");\n\n// INFLOW\nlet strategy = flow.get(\"house_battery_strategy\");\n\n// Configure msg.strategy object\nRED.util.setMessageProperty(msg,\"strategy.selected\",strategy,true); // by user\nRED.util.setMessageProperty(msg,\"strategy.trace\",[],true); // init trace: which flows have been executed\n\n// stop on error\nif(msg.batteries === undefined) {\n logger(this, \"Battery configuration undefined\", \"error\");\n return null;\n}\nif(strategy === undefined) {\n logger(this, \"Battery strategy undefined\", \"error\");\n return null;\n}\n\n// Default | select target flow based on the input_select `house_battery_strategy`\nmsg.target = `${strategy}`;\n\n// EV stop trigger overrules selected strategies, except a manual Full stop.\nconst stopTrigger = RED.util.getMessageProperty(msg,\"full_stop_trigger\");\nconst isStopTriggerActive = stopTrigger == \"on\" || stopTrigger === true || stopTrigger === 1;\nconst stopTriggerStrategy = RED.util.getMessageProperty(msg,\"ev_stop_trigger_strategy\") || \"Full stop\";\nif(isStopTriggerActive && strategy !== \"Full stop\" && stopTriggerStrategy !== \"Disabled\") {\n msg.target = stopTriggerStrategy;\n // Explain\n logger(this, `**${stopTriggerStrategy}** strategy selected, due to EV stop trigger: '${stopTrigger}'`);\n}\n\n// add execution marker\nRED.util.setMessageProperty(msg,\"strategy.execution_start\",Date.now()); // when do we start executing the selected strategy\n\n// OUTPUT\nnode.status({fill:\"grey\",shape:\"dot\",text:msg.target});\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -3620,4 +3620,4 @@ "node-red-contrib-home-assistant-websocket": "0.80.3" } } -] \ No newline at end of file +] diff --git a/node-red/02 strategy-partials.json b/node-red/02 strategy-partials.json index 5ac136c..cf512e4 100644 --- a/node-red/02 strategy-partials.json +++ b/node-red/02 strategy-partials.json @@ -341,7 +341,7 @@ "type": "function", "z": "1ae53b821b8673a2", "name": "Mode selection", - "func": "/* Mode selection \n * \n * Peak shaving can be triggered by:\n * - Grid power exceeds the import/export limit set by the user\n * \n * Peak shaving will persist while:\n * - Grid power exceeds the import/export limit set by the user\n * - Batteries are applied to keep system below the limit set by the user\n * \n * The first is checked in this node.\n * The latter is checked in the last function node of the Peak Shaving flow.\n * Both set the last_power_limit_violation flow variable to Date.now() to persist peak shaving.\n * \n * PEAK_SHAVING_TIMEOUT_SEC is advised to be kept above atleast 3-5 seconds, to account for rate limiters and other power saving options.\n*/\n\n// Timestamp\nconst now = Date.now();\n\n// Logger\nconst log = global.get(\"logger\");\n\n// Configuration & State recovery\nconst PEAK_SHAVING_TIMEOUT_SEC = 10;\nlet isPeakShaving = flow.get(\"is_peak_shaving\") ?? false;\nlet lastPowerLimitViolation = flow.get(\"last_power_limit_violation\") ?? now;\nlet isPowerLimitViolation = false;\nlet peakDirection = flow.get(\"peak_shave_direction\") || null;\nlet powerLimit = Number(flow.get(\"peak_shave_power_limit\")) || 0;\n\n// Input data | grid\nconst P1_power = parseInt(RED.util.getMessageProperty(msg, \"grid_power\") || 0);\n// Input data | Charge PV\nconst hasImportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_import\") || false;\nconst importLimit = parseInt(RED.util.getMessageProperty(msg, \"grid_power_limit_import\") || 0);\n// Input data | Sell PV\nconst hasExportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_export\") || false;\nconst exportLimit = parseInt(RED.util.getMessageProperty(msg, \"grid_power_limit_export\") || 0);\n\n// ACTIVATE shaving\n// Latch the direction and limit at the moment of the real violation. During the\n// release timeout the battery response can flip P1 import/export, but that\n// should not turn an import shave into an export shave or vice versa.\nif (hasImportLimit && (P1_power > importLimit)) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n peakDirection = \"import\";\n powerLimit = importLimit;\n}\nif (hasExportLimit && (P1_power < exportLimit)) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n peakDirection = \"export\";\n powerLimit = exportLimit;\n}\n\n// UPDATE timer or continue\nif (isPowerLimitViolation) lastPowerLimitViolation = now;\n\n// CALCULATE release logic\n// Seconds since the last time the power limit was exceeded\nlet secondsSinceLastViolation = Math.floor((now - lastPowerLimitViolation) / 1000);\n\n// RELEASE shaving logic\n// Only release if we are currently shaving AND the timeout has passed\nif (isPeakShaving && (secondsSinceLastViolation >= PEAK_SHAVING_TIMEOUT_SEC)) {\n isPeakShaving = false;\n peakDirection = null;\n powerLimit = 0;\n log(this,\"Peak Shaving released, resume normal operation\");\n}\n\n// UI & OUTPUT Logic\nif (isPeakShaving) {\n // Timeout progress\n const secondsRemaining = PEAK_SHAVING_TIMEOUT_SEC - secondsSinceLastViolation;\n // Set latched grid power limit\n msg.payload = powerLimit;\n // UI\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: isPowerLimitViolation ? \"Peak Shaving\" : \"Peak Shaving: release in \" + secondsRemaining + \"s\"\n });\n} else {\n // UI\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: \"Grid OK - \" + msg.target\n });\n}\n\n// Persist state for next run\nflow.set(\"is_peak_shaving\", isPeakShaving); \nflow.set(\"peak_shave_direction\", peakDirection);\nflow.set(\"peak_shave_power_limit\", powerLimit);\nif (isPowerLimitViolation) flow.set(\"last_power_limit_violation\", now); // Power limit exceeded. Update the lastPowerLimitViolation timestamp to current time\n\n// Finalize msg\nRED.util.setMessageProperty(msg, \"strategy.is_peak_shaving\", isPeakShaving, true);\nRED.util.setMessageProperty(msg, \"strategy.peak_direction\", peakDirection, true);\n\n// OUTPUT\nreturn msg;", + "func": "/* Mode selection \n * \n * Peak shaving can be triggered by:\n * - Grid power exceeds the import/export limit set by the user\n * \n * Peak shaving will persist while:\n * - Grid power exceeds the import/export limit set by the user\n * - Batteries are applied to keep system below the limit set by the user\n * \n * The first is checked in this node.\n * The latter is checked in the last function node of the Peak Shaving flow.\n * Both set the last_power_limit_violation flow variable to Date.now() to persist peak shaving.\n * \n * PEAK_SHAVING_TIMEOUT_SEC is advised to be kept above atleast 3-5 seconds, to account for rate limiters and other power saving options.\n*/\n\n// Timestamp\nconst now = Date.now();\n\n// Logger\nconst log = global.get(\"logger\");\n\n// Configuration & State recovery\nconst PEAK_SHAVING_TIMEOUT_SEC = 10;\nlet isPeakShaving = flow.get(\"is_peak_shaving\") ?? false;\nlet lastPowerLimitViolation = flow.get(\"last_power_limit_violation\") ?? now;\nlet isPowerLimitViolation = false;\nlet peakDirection = flow.get(\"peak_shave_direction\") || null;\nlet powerLimit = Number(flow.get(\"peak_shave_power_limit\")) || 0;\n\n// Input data | grid\n// During Charge, exclude the current battery charging power from import-peak detection.\n// Regulated Charge already uses the import limit as its PID target; counting its own\n// load here would make it trigger peak shaving unnecessarily.\nconst measuredGridPower = parseInt(RED.util.getMessageProperty(msg, \"grid_power\") || 0);\nconst batteryChargePower = msg.target === \"Charge\"\n ? Math.max(0, Number(RED.util.getMessageProperty(msg, \"batteries_total_power\")) || 0)\n : 0;\nconst P1_power = measuredGridPower - batteryChargePower;\n// Input data | Charge PV\nconst hasImportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_import\") || false;\nconst importLimit = parseInt(RED.util.getMessageProperty(msg, \"grid_power_limit_import\") || 0);\n// Input data | Sell PV\nconst hasExportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_export\") || false;\nconst exportLimit = parseInt(RED.util.getMessageProperty(msg, \"grid_power_limit_export\") || 0);\n\n// ACTIVATE shaving\n// Latch the direction and limit at the moment of the real violation. During the\n// release timeout the battery response can flip P1 import/export, but that\n// should not turn an import shave into an export shave or vice versa.\nif (hasImportLimit && (P1_power > importLimit)) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n peakDirection = \"import\";\n powerLimit = importLimit;\n}\nif (hasExportLimit && (P1_power < exportLimit)) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n peakDirection = \"export\";\n powerLimit = exportLimit;\n}\n\n// UPDATE timer or continue\nif (isPowerLimitViolation) lastPowerLimitViolation = now;\n\n// CALCULATE release logic\n// Seconds since the last time the power limit was exceeded\nlet secondsSinceLastViolation = Math.floor((now - lastPowerLimitViolation) / 1000);\n\n// RELEASE shaving logic\n// Only release if we are currently shaving AND the timeout has passed\nif (isPeakShaving && (secondsSinceLastViolation >= PEAK_SHAVING_TIMEOUT_SEC)) {\n isPeakShaving = false;\n peakDirection = null;\n powerLimit = 0;\n log(this,\"Peak Shaving released, resume normal operation\");\n}\n\n// UI & OUTPUT Logic\nif (isPeakShaving) {\n // Timeout progress\n const secondsRemaining = PEAK_SHAVING_TIMEOUT_SEC - secondsSinceLastViolation;\n // Set latched grid power limit\n msg.payload = powerLimit;\n // UI\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: isPowerLimitViolation ? \"Peak Shaving\" : \"Peak Shaving: release in \" + secondsRemaining + \"s\"\n });\n} else {\n // UI\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: \"Grid OK - \" + msg.target\n });\n}\n\n// Persist state for next run\nflow.set(\"is_peak_shaving\", isPeakShaving); \nflow.set(\"peak_shave_direction\", peakDirection);\nflow.set(\"peak_shave_power_limit\", powerLimit);\nif (isPowerLimitViolation) flow.set(\"last_power_limit_violation\", now); // Power limit exceeded. Update the lastPowerLimitViolation timestamp to current time\n\n// Finalize msg\nRED.util.setMessageProperty(msg, \"strategy.is_peak_shaving\", isPeakShaving, true);\nRED.util.setMessageProperty(msg, \"strategy.peak_direction\", peakDirection, true);\n\n// OUTPUT\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, diff --git a/node-red/all-flows-in-one-file.json b/node-red/all-flows-in-one-file.json index 1f90040..5f729b7 100644 --- a/node-red/all-flows-in-one-file.json +++ b/node-red/all-flows-in-one-file.json @@ -2916,7 +2916,7 @@ "z": "b3306a83536ac1ba", "g": "6cdf174b958506ac", "name": "Strategy selection", - "func": "// Logger\nconst logger = global.get(\"logger\");\n\n// INFLOW\nlet strategy = flow.get(\"house_battery_strategy\");\n\n// Configure msg.strategy object\nRED.util.setMessageProperty(msg,\"strategy.selected\",strategy,true); // by user\nRED.util.setMessageProperty(msg,\"strategy.trace\",[],true); // init trace: which flows have been executed\n\n// stop on error\nif(msg.batteries === undefined) {\n logger(this, \"Battery configuration undefined\", \"error\");\n return null;\n}\nif(strategy === undefined) {\n logger(this, \"Battery strategy undefined\", \"error\");\n return null;\n}\n\n// Default | select target flow based on the input_select `house_battery_strategy`\nmsg.target = `${strategy}`;\n\n// EV stop trigger overrules selected strategies, except a manual Full stop.\nconst stopTrigger = RED.util.getMessageProperty(msg,\"full_stop_trigger\");\nconst isStopTriggerActive = stopTrigger == \"on\" || stopTrigger === true || stopTrigger === 1;\nif(isStopTriggerActive && strategy !== \"Full stop\") {\n const stopTriggerStrategy = RED.util.getMessageProperty(msg,\"ev_stop_trigger_strategy\") || \"Full stop\";\n msg.target = stopTriggerStrategy;\n // Explain\n logger(this, `**${stopTriggerStrategy}** strategy selected, due to EV stop trigger: '${stopTrigger}'`);\n}\n\n// add execution marker\nRED.util.setMessageProperty(msg,\"strategy.execution_start\",Date.now()); // when do we start executing the selected strategy\n\n// OUTPUT\nnode.status({fill:\"grey\",shape:\"dot\",text:msg.target});\nreturn msg;", + "func": "// Logger\nconst logger = global.get(\"logger\");\n\n// INFLOW\nlet strategy = flow.get(\"house_battery_strategy\");\n\n// Configure msg.strategy object\nRED.util.setMessageProperty(msg,\"strategy.selected\",strategy,true); // by user\nRED.util.setMessageProperty(msg,\"strategy.trace\",[],true); // init trace: which flows have been executed\n\n// stop on error\nif(msg.batteries === undefined) {\n logger(this, \"Battery configuration undefined\", \"error\");\n return null;\n}\nif(strategy === undefined) {\n logger(this, \"Battery strategy undefined\", \"error\");\n return null;\n}\n\n// Default | select target flow based on the input_select `house_battery_strategy`\nmsg.target = `${strategy}`;\n\n// EV stop trigger overrules selected strategies, except a manual Full stop.\nconst stopTrigger = RED.util.getMessageProperty(msg,\"full_stop_trigger\");\nconst isStopTriggerActive = stopTrigger == \"on\" || stopTrigger === true || stopTrigger === 1;\nconst stopTriggerStrategy = RED.util.getMessageProperty(msg,\"ev_stop_trigger_strategy\") || \"Full stop\";\nif(isStopTriggerActive && strategy !== \"Full stop\" && stopTriggerStrategy !== \"Disabled\") {\n msg.target = stopTriggerStrategy;\n // Explain\n logger(this, `**${stopTriggerStrategy}** strategy selected, due to EV stop trigger: '${stopTrigger}'`);\n}\n\n// add execution marker\nRED.util.setMessageProperty(msg,\"strategy.execution_start\",Date.now()); // when do we start executing the selected strategy\n\n// OUTPUT\nnode.status({fill:\"grey\",shape:\"dot\",text:msg.target});\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -6142,7 +6142,7 @@ "type": "function", "z": "9c5e9dbe3c6522d6", "name": "Mode selection", - "func": "/* Mode selection \n * \n * Peak shaving can be triggered by:\n * - Grid power exceeds the import/export limit set by the user\n * \n * Peak shaving will persist while:\n * - Grid power exceeds the import/export limit set by the user\n * - Batteries are applied to keep system below the limit set by the user\n * \n * The first is checked in this node.\n * The latter is checked in the last function node of the Peak Shaving flow.\n * Both set the last_power_limit_violation flow variable to Date.now() to persist peak shaving.\n * \n * PEAK_SHAVING_TIMEOUT_SEC is advised to be kept above atleast 3-5 seconds, to account for rate limiters and other power saving options.\n*/\n\n// Timestamp\nconst now = Date.now();\n\n// Logger\nconst log = global.get(\"logger\");\n\n// Configuration & State recovery\nconst PEAK_SHAVING_TIMEOUT_SEC = 10;\nlet isPeakShaving = flow.get(\"is_peak_shaving\") ?? false;\nlet lastPowerLimitViolation = flow.get(\"last_power_limit_violation\") ?? now;\nlet isPowerLimitViolation = false;\nlet peakDirection = flow.get(\"peak_shave_direction\") || null;\nlet powerLimit = Number(flow.get(\"peak_shave_power_limit\")) || 0;\n\n// Input data | grid\nconst P1_power = parseInt(RED.util.getMessageProperty(msg, \"grid_power\") || 0);\n// Input data | Charge PV\nconst hasImportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_import\") || false;\nconst importLimit = parseInt(RED.util.getMessageProperty(msg, \"grid_power_limit_import\") || 0);\n// Input data | Sell PV\nconst hasExportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_export\") || false;\nconst exportLimit = parseInt(RED.util.getMessageProperty(msg, \"grid_power_limit_export\") || 0);\n\n// ACTIVATE shaving\n// Latch the direction and limit at the moment of the real violation. During the\n// release timeout the battery response can flip P1 import/export, but that\n// should not turn an import shave into an export shave or vice versa.\nif (hasImportLimit && (P1_power > importLimit)) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n peakDirection = \"import\";\n powerLimit = importLimit;\n}\nif (hasExportLimit && (P1_power < exportLimit)) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n peakDirection = \"export\";\n powerLimit = exportLimit;\n}\n\n// UPDATE timer or continue\nif (isPowerLimitViolation) lastPowerLimitViolation = now;\n\n// CALCULATE release logic\n// Seconds since the last time the power limit was exceeded\nlet secondsSinceLastViolation = Math.floor((now - lastPowerLimitViolation) / 1000);\n\n// RELEASE shaving logic\n// Only release if we are currently shaving AND the timeout has passed\nif (isPeakShaving && (secondsSinceLastViolation >= PEAK_SHAVING_TIMEOUT_SEC)) {\n isPeakShaving = false;\n peakDirection = null;\n powerLimit = 0;\n log(this,\"Peak Shaving released, resume normal operation\");\n}\n\n// UI & OUTPUT Logic\nif (isPeakShaving) {\n // Timeout progress\n const secondsRemaining = PEAK_SHAVING_TIMEOUT_SEC - secondsSinceLastViolation;\n // Set latched grid power limit\n msg.payload = powerLimit;\n // UI\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: isPowerLimitViolation ? \"Peak Shaving\" : \"Peak Shaving: release in \" + secondsRemaining + \"s\"\n });\n} else {\n // UI\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: \"Grid OK - \" + msg.target\n });\n}\n\n// Persist state for next run\nflow.set(\"is_peak_shaving\", isPeakShaving); \nflow.set(\"peak_shave_direction\", peakDirection);\nflow.set(\"peak_shave_power_limit\", powerLimit);\nif (isPowerLimitViolation) flow.set(\"last_power_limit_violation\", now); // Power limit exceeded. Update the lastPowerLimitViolation timestamp to current time\n\n// Finalize msg\nRED.util.setMessageProperty(msg, \"strategy.is_peak_shaving\", isPeakShaving, true);\nRED.util.setMessageProperty(msg, \"strategy.peak_direction\", peakDirection, true);\n\n// OUTPUT\nreturn msg;", + "func": "/* Mode selection \n * \n * Peak shaving can be triggered by:\n * - Grid power exceeds the import/export limit set by the user\n * \n * Peak shaving will persist while:\n * - Grid power exceeds the import/export limit set by the user\n * - Batteries are applied to keep system below the limit set by the user\n * \n * The first is checked in this node.\n * The latter is checked in the last function node of the Peak Shaving flow.\n * Both set the last_power_limit_violation flow variable to Date.now() to persist peak shaving.\n * \n * PEAK_SHAVING_TIMEOUT_SEC is advised to be kept above atleast 3-5 seconds, to account for rate limiters and other power saving options.\n*/\n\n// Timestamp\nconst now = Date.now();\n\n// Logger\nconst log = global.get(\"logger\");\n\n// Configuration & State recovery\nconst PEAK_SHAVING_TIMEOUT_SEC = 10;\nlet isPeakShaving = flow.get(\"is_peak_shaving\") ?? false;\nlet lastPowerLimitViolation = flow.get(\"last_power_limit_violation\") ?? now;\nlet isPowerLimitViolation = false;\nlet peakDirection = flow.get(\"peak_shave_direction\") || null;\nlet powerLimit = Number(flow.get(\"peak_shave_power_limit\")) || 0;\n\n// Input data | grid\n// During Charge, exclude the current battery charging power from import-peak detection.\n// Regulated Charge already uses the import limit as its PID target; counting its own\n// load here would make it trigger peak shaving unnecessarily.\nconst measuredGridPower = parseInt(RED.util.getMessageProperty(msg, \"grid_power\") || 0);\nconst batteryChargePower = msg.target === \"Charge\"\n ? Math.max(0, Number(RED.util.getMessageProperty(msg, \"batteries_total_power\")) || 0)\n : 0;\nconst P1_power = measuredGridPower - batteryChargePower;\n// Input data | Charge PV\nconst hasImportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_import\") || false;\nconst importLimit = parseInt(RED.util.getMessageProperty(msg, \"grid_power_limit_import\") || 0);\n// Input data | Sell PV\nconst hasExportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_export\") || false;\nconst exportLimit = parseInt(RED.util.getMessageProperty(msg, \"grid_power_limit_export\") || 0);\n\n// ACTIVATE shaving\n// Latch the direction and limit at the moment of the real violation. During the\n// release timeout the battery response can flip P1 import/export, but that\n// should not turn an import shave into an export shave or vice versa.\nif (hasImportLimit && (P1_power > importLimit)) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n peakDirection = \"import\";\n powerLimit = importLimit;\n}\nif (hasExportLimit && (P1_power < exportLimit)) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n peakDirection = \"export\";\n powerLimit = exportLimit;\n}\n\n// UPDATE timer or continue\nif (isPowerLimitViolation) lastPowerLimitViolation = now;\n\n// CALCULATE release logic\n// Seconds since the last time the power limit was exceeded\nlet secondsSinceLastViolation = Math.floor((now - lastPowerLimitViolation) / 1000);\n\n// RELEASE shaving logic\n// Only release if we are currently shaving AND the timeout has passed\nif (isPeakShaving && (secondsSinceLastViolation >= PEAK_SHAVING_TIMEOUT_SEC)) {\n isPeakShaving = false;\n peakDirection = null;\n powerLimit = 0;\n log(this,\"Peak Shaving released, resume normal operation\");\n}\n\n// UI & OUTPUT Logic\nif (isPeakShaving) {\n // Timeout progress\n const secondsRemaining = PEAK_SHAVING_TIMEOUT_SEC - secondsSinceLastViolation;\n // Set latched grid power limit\n msg.payload = powerLimit;\n // UI\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: isPowerLimitViolation ? \"Peak Shaving\" : \"Peak Shaving: release in \" + secondsRemaining + \"s\"\n });\n} else {\n // UI\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: \"Grid OK - \" + msg.target\n });\n}\n\n// Persist state for next run\nflow.set(\"is_peak_shaving\", isPeakShaving); \nflow.set(\"peak_shave_direction\", peakDirection);\nflow.set(\"peak_shave_power_limit\", powerLimit);\nif (isPowerLimitViolation) flow.set(\"last_power_limit_violation\", now); // Power limit exceeded. Update the lastPowerLimitViolation timestamp to current time\n\n// Finalize msg\nRED.util.setMessageProperty(msg, \"strategy.is_peak_shaving\", isPeakShaving, true);\nRED.util.setMessageProperty(msg, \"strategy.peak_direction\", peakDirection, true);\n\n// OUTPUT\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -14975,4 +14975,4 @@ ] ] } -] \ No newline at end of file +] From 33a51594fa6611832e65cf0354f8c2ed3a3ab15e Mon Sep 17 00:00:00 2001 From: joma999 Date: Wed, 19 Aug 2026 15:46:21 +0200 Subject: [PATCH 4/4] docs: explain dynamic switching and EV charge safeguards --- docs/05-setup-dynamic.md | 2 ++ docs/06-advanced-features.md | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/05-setup-dynamic.md b/docs/05-setup-dynamic.md index 2cb1181..4cd7772 100644 --- a/docs/05-setup-dynamic.md +++ b/docs/05-setup-dynamic.md @@ -75,6 +75,8 @@ The Dynamic controls live in the `Timed/Dynamic` tab of the Home Battery Control | **Regular period strategy** | Action during neutral (unmarked) intervals. Choices: `Charge PV`, `Self-consumption`, `Full stop`. | | **High period strategy** | Action during `high`-marked intervals. Choices: `Self-consumption`, `Sell`, `Charge PV`, `Full stop`. | +Changes to any of these period strategies take effect immediately. If the current price interval is low, neutral, or high, Dynamic re-evaluates that interval and switches to the newly selected sub-strategy without needing to stop and restart Dynamic. + > **Tip:** the underlying `Charge` / `Sell` strategies can be configured with solar forecast, peak-shaving reserves, etc. Dynamic respects those settings. The price-data table (today and tomorrow with marks) and an ApexChart for review are visible in the same tab when debug/insights mode is enabled. diff --git a/docs/06-advanced-features.md b/docs/06-advanced-features.md index a0eb0c4..b38b2b8 100644 --- a/docs/06-advanced-features.md +++ b/docs/06-advanced-features.md @@ -12,9 +12,13 @@ nav_order: 6 - Configure by entering the `entity_id` of an `input_boolean` or `on/off` template sensor - The trigger sensor should indicate when your EV or heavy appliance is actively charging - Choose the trigger strategy: + - **Disabled:** retain the configured sensor but do not override the active battery strategy - **Full stop:** stop battery operation until the trigger sensor returns to off - **Standby / peak shave:** keep normal battery operation idle, but allow peak shaving when grid limits are exceeded - - Manual **Full stop** in the main strategy selector always takes precedence, even when the EV trigger strategy is set to `Standby / peak shave` + - **Charge PV:** charge only from surplus solar while the trigger is active + - **Charge:** charge using the configured Charge strategy while the trigger is active + - Requires sufficient grid capacity and/or EV charger load balancing. Otherwise simultaneous EV and battery charging can overload the grid connection. + - Manual **Full stop** in the main strategy selector always takes precedence over the EV trigger strategy - Useful for preventing home battery discharge during high-power EV charging sessions - Configurable through the Advanced Settings dashboard @@ -126,6 +130,7 @@ Peak Shaving helps reduce import and export peaks on your grid connection by int - Set your **export limit** on the "Settings" tab (maximum power you want to feed back to the grid) - Note: Most capacity tariff contracts only require import limiting - Peak Shaving integrates seamlessly with Charge, Self-consumption, Sell, Dynamic, and Timed strategies +- During **Charge**, the battery's measured charging power is excluded from import-peak detection. Regulated Charge uses the configured import limit as its PID target, filling only the remaining grid headroom; peak shaving still activates when the non-battery load itself exceeds the limit. - Full Stop strategy takes precedence and will not be overridden by peak shaving **Limitations to understand:**