diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 689fa65..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index 3565fd3..adf601a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,18 @@ RELEASE_NOTES_TEMP.txt # Ignore notes and development files notes/ template_sensor_bob_battery_dashboard.yaml +.DS_Store + +# Ignore opencode local workspace +.opencode/ -# Ignore SASS cache files docs/.sass-cache +# Node.js dependencies and test output +node_modules/ + +coverage/ + # Claude Code per-user state — keep local; commands/skills/agents/settings.json are shared .claude/settings.local.json -.claude/sessions/ \ No newline at end of file +.claude/sessions/ diff --git a/docs/06-advanced-features.md b/docs/06-advanced-features.md index 0e17303..9f474c1 100644 --- a/docs/06-advanced-features.md +++ b/docs/06-advanced-features.md @@ -64,6 +64,12 @@ nav_order: 6 ## Multi-Battery Management - **More than 6 batteries:** Override or change `input_number.house_battery_count` and you are good to go. - The dashboard supports 6 batteries out of the box. For 7 or more, duplicate and edit these cards or create your own dashboard. +- **Manual phase assignment:** Assign each configured battery to `L1`, `L2`, `L3`, or `Unassigned` from the dashboard. + - The overview shows the assigned phase on each battery header and shows live battery AC power per phase in kW. + - The Node-RED battery object exposes this as `battery.phase`, so custom strategies can use the mapping. + - Optional per-phase grid power aliases can be configured in `packages/house_battery_control_config.yaml` as `sensor.p1_meter_l1_power`, `sensor.p1_meter_l2_power`, and `sensor.p1_meter_l3_power`. Leave them commented out if your setup is not three-phase. + - Node-RED exposes configured phase meter values as `msg.grid_power_phase.L1`, `.L2`, and `.L3`, with missing or unreadable values set to `null`. + - Built-in strategies still use aggregate control by default; per-phase control/peak shaving is not enforced yet. - **3-Phase self-consumption:** if you require 0 W grid consumption on a per phase basis, the setup changes slightly. Note: most homes get billed for the net total of all phases. If that is the case for you as well, ignore these instructions. @@ -94,6 +100,7 @@ Controls grid import/export thresholds for `peak shaving` functionality. - **Import limit:** Maximum power to draw from the grid (example: 16A × 230V = 3680W for CAPTAR contracts) - **Export limit:** Maximum power to feed back to the grid (example: 3000W if grid connection has export limits) +- **Max phase power:** Shared per-phase safety threshold for future/custom phase-aware strategies. This value is exposed to Node-RED as `msg.grid_power_limit_phase`, but built-in strategies do not enforce it yet. - **Configuration:** Adjust from the "Settings" tab in the Home Assistant dashboard ### Charge / Sell Power Mode diff --git a/home assistant/.DS_Store b/home assistant/.DS_Store deleted file mode 100644 index 8c1d6d6..0000000 Binary files a/home assistant/.DS_Store and /dev/null differ diff --git a/home assistant/dashboard.yaml b/home assistant/dashboard.yaml index d661ddf..1189274 100644 --- a/home assistant/dashboard.yaml +++ b/home assistant/dashboard.yaml @@ -410,9 +410,30 @@ views: columns: full rows: 1 - type: heading - heading: Marstek M1 + heading: Battery Phase Interaction heading_style: subtitle - icon: '' + icon: mdi:transmission-tower + visibility: + - condition: state + entity: binary_sensor.house_battery_has_phase_assignment + state: 'on' + - type: glance + entities: + - entity: sensor.house_battery_l1_power + name: L1 + - entity: sensor.house_battery_l2_power + name: L2 + - entity: sensor.house_battery_l3_power + name: L3 + columns: 3 + visibility: + - condition: state + entity: binary_sensor.house_battery_has_phase_assignment + state: 'on' + - type: markdown + content: >- + ### Marstek M1{% set phase = states('input_select.marstek_m1_phase') %}{% if phase == 'L1' %} - Phase 1{% elif phase == 'L2' %} - Phase 2{% elif phase == 'L3' %} - Phase 3{% endif %} + text_only: true visibility: - condition: state entity: sensor.marstek_m1_device_name @@ -431,10 +452,10 @@ views: - condition: state entity: sensor.marstek_m1_device_name state_not: unknown - - type: heading - heading: Marstek M2 - heading_style: subtitle - icon: '' + - type: markdown + content: >- + ### Marstek M2{% set phase = states('input_select.marstek_m2_phase') %}{% if phase == 'L1' %} - Phase 1{% elif phase == 'L2' %} - Phase 2{% elif phase == 'L3' %} - Phase 3{% endif %} + text_only: true visibility: - condition: and conditions: @@ -463,10 +484,10 @@ views: - condition: numeric_state entity: input_number.house_battery_count above: 1 - - type: heading - heading: Marstek M3 - heading_style: subtitle - icon: '' + - type: markdown + content: >- + ### Marstek M3{% set phase = states('input_select.marstek_m3_phase') %}{% if phase == 'L1' %} - Phase 1{% elif phase == 'L2' %} - Phase 2{% elif phase == 'L3' %} - Phase 3{% endif %} + text_only: true visibility: - condition: and conditions: @@ -495,10 +516,10 @@ views: - condition: numeric_state entity: input_number.house_battery_count above: 2 - - type: heading - heading: Marstek M4 - heading_style: subtitle - icon: '' + - type: markdown + content: >- + ### Marstek M4{% set phase = states('input_select.marstek_m4_phase') %}{% if phase == 'L1' %} - Phase 1{% elif phase == 'L2' %} - Phase 2{% elif phase == 'L3' %} - Phase 3{% endif %} + text_only: true visibility: - condition: and conditions: @@ -527,10 +548,10 @@ views: - condition: numeric_state entity: input_number.house_battery_count above: 3 - - type: heading - heading: Marstek M5 - heading_style: subtitle - icon: '' + - type: markdown + content: >- + ### Marstek M5{% set phase = states('input_select.marstek_m5_phase') %}{% if phase == 'L1' %} - Phase 1{% elif phase == 'L2' %} - Phase 2{% elif phase == 'L3' %} - Phase 3{% endif %} + text_only: true visibility: - condition: and conditions: @@ -559,10 +580,10 @@ views: - condition: numeric_state entity: input_number.house_battery_count above: 4 - - type: heading - heading: Marstek M6 - heading_style: subtitle - icon: '' + - type: markdown + content: >- + ### Marstek M6{% set phase = states('input_select.marstek_m6_phase') %}{% if phase == 'L1' %} - Phase 1{% elif phase == 'L2' %} - Phase 2{% elif phase == 'L3' %} - Phase 3{% endif %} + text_only: true visibility: - condition: and conditions: @@ -1848,6 +1869,24 @@ views: text_only: true grid_options: columns: full + - type: grid + cards: + - type: heading + icon: mdi:fuse + heading: Per-phase safety limit + heading_style: subtitle + - type: entities + entities: + - entity: input_number.house_battery_control_max_phase_power + name: Max phase power + - type: markdown + content: >- + Available for custom and future phase-aware + strategies. Built-in strategies do not enforce this limit + yet. + text_only: true + grid_options: + columns: full - type: grid cards: - type: heading @@ -1890,6 +1929,10 @@ views: icon: mdi:power-plug-battery heading: Marstek M1 heading_style: title + - type: entities + entities: + - entity: input_select.marstek_m1_phase + name: Phase - type: vertical-stack cards: - type: markdown @@ -1959,6 +2002,10 @@ views: icon: mdi:power-plug-battery heading: Marstek M2 heading_style: title + - type: entities + entities: + - entity: input_select.marstek_m2_phase + name: Phase - type: vertical-stack cards: - type: markdown @@ -2024,6 +2071,10 @@ views: icon: mdi:power-plug-battery heading: Marstek M3 heading_style: title + - type: entities + entities: + - entity: input_select.marstek_m3_phase + name: Phase - type: vertical-stack cards: - type: markdown @@ -2089,6 +2140,10 @@ views: icon: mdi:power-plug-battery heading: Marstek M4 heading_style: title + - type: entities + entities: + - entity: input_select.marstek_m4_phase + name: Phase - type: vertical-stack cards: - type: markdown @@ -2154,6 +2209,10 @@ views: icon: mdi:power-plug-battery heading: Marstek M5 heading_style: title + - type: entities + entities: + - entity: input_select.marstek_m5_phase + name: Phase - type: vertical-stack cards: - type: markdown @@ -2219,6 +2278,10 @@ views: icon: mdi:power-plug-battery heading: Marstek M6 heading_style: title + - type: entities + entities: + - entity: input_select.marstek_m6_phase + name: Phase - type: vertical-stack cards: - type: markdown diff --git a/home assistant/packages/house_battery_control.yaml b/home assistant/packages/house_battery_control.yaml index eb3e011..4661961 100644 --- a/home assistant/packages/house_battery_control.yaml +++ b/home assistant/packages/house_battery_control.yaml @@ -388,6 +388,17 @@ input_number: mode: box icon: mdi:home-export-outline + # Peak shaving | per-phase threshold for custom/future phase-aware strategies + house_battery_control_max_phase_power: + name: Max phase power + min: 500 + max: 25000 + initial: 5500 + step: 1 + unit_of_measurement: "W" + mode: box + icon: mdi:transmission-tower + # Strategy charge | desired energy reserve (effective energy available excl. cut-off energies) # old name: house_battery_strategy_timed_target_energy house_battery_strategy_charge_target_energy: @@ -659,6 +670,41 @@ input_select: - Full control # Full control over the battery, no Marstek control icon: mdi:battery-heart + # Battery phase assignments + marstek_m1_phase: + name: "Marstek M1 Phase" + options: &BatteryPhaseOptions + - Unassigned + - L1 + - L2 + - L3 + icon: mdi:transmission-tower + + marstek_m2_phase: + name: "Marstek M2 Phase" + options: *BatteryPhaseOptions + icon: mdi:transmission-tower + + marstek_m3_phase: + name: "Marstek M3 Phase" + options: *BatteryPhaseOptions + icon: mdi:transmission-tower + + marstek_m4_phase: + name: "Marstek M4 Phase" + options: *BatteryPhaseOptions + icon: mdi:transmission-tower + + marstek_m5_phase: + name: "Marstek M5 Phase" + options: *BatteryPhaseOptions + icon: mdi:transmission-tower + + marstek_m6_phase: + name: "Marstek M6 Phase" + options: *BatteryPhaseOptions + icon: mdi:transmission-tower + # Master strategy house_battery_strategy: name: House Battery Strategy @@ -868,6 +914,98 @@ template: {# Marstek uses negative power when delivering power, positive when charging. This is inverse from what HA expects.#} {{ (total_power * -1) | round(2) }} + # AC-side phase interaction totals. + # Optional grid-side phase meter aliases are configured in + # house_battery_control_config.yaml as sensor.p1_meter_l1_power, + # sensor.p1_meter_l2_power and sensor.p1_meter_l3_power. + + # Live AC power on phase L1 + - name: "House Battery L1 Power" + unique_id: "house_battery_l1_power_kw" + state_class: measurement + device_class: "power" + unit_of_measurement: "kW" + icon: mdi:transmission-tower + state: > + {% set total_w = namespace(value=0) %} + {% if states('input_select.marstek_m1_phase') == 'L1' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m1_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m2_phase') == 'L1' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m2_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m3_phase') == 'L1' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m3_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m4_phase') == 'L1' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m4_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m5_phase') == 'L1' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m5_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m6_phase') == 'L1' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m6_ac_power') | float(0)) %} + {% endif %} + {{ ((total_w.value * -1) / 1000) | round(2) }} + + # Live AC power on phase L2 + - name: "House Battery L2 Power" + unique_id: "house_battery_l2_power_kw" + state_class: measurement + device_class: "power" + unit_of_measurement: "kW" + icon: mdi:transmission-tower + state: > + {% set total_w = namespace(value=0) %} + {% if states('input_select.marstek_m1_phase') == 'L2' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m1_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m2_phase') == 'L2' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m2_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m3_phase') == 'L2' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m3_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m4_phase') == 'L2' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m4_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m5_phase') == 'L2' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m5_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m6_phase') == 'L2' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m6_ac_power') | float(0)) %} + {% endif %} + {{ ((total_w.value * -1) / 1000) | round(2) }} + + # Live AC power on phase L3 + - name: "House Battery L3 Power" + unique_id: "house_battery_l3_power_kw" + state_class: measurement + device_class: "power" + unit_of_measurement: "kW" + icon: mdi:transmission-tower + state: > + {% set total_w = namespace(value=0) %} + {% if states('input_select.marstek_m1_phase') == 'L3' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m1_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m2_phase') == 'L3' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m2_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m3_phase') == 'L3' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m3_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m4_phase') == 'L3' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m4_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m5_phase') == 'L3' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m5_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m6_phase') == 'L3' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m6_ac_power') | float(0)) %} + {% endif %} + {{ ((total_w.value * -1) / 1000) | round(2) }} + # Time Remaining - name: "Battery time remaining" unique_id: house_battery_time_remaining @@ -959,6 +1097,18 @@ template: {{ [max_available - filled, 0] | max | round(2) if max_available > 0 else 0 }} - binary_sensor: + # Battery phase assignments | has any battery been assigned to a phase? + - name: "House Battery Has Phase Assignment" + unique_id: house_battery_has_phase_assignment + icon: mdi:transmission-tower + state: > + {{ states('input_select.marstek_m1_phase') in ['L1', 'L2', 'L3'] or + states('input_select.marstek_m2_phase') in ['L1', 'L2', 'L3'] or + states('input_select.marstek_m3_phase') in ['L1', 'L2', 'L3'] or + states('input_select.marstek_m4_phase') in ['L1', 'L2', 'L3'] or + states('input_select.marstek_m5_phase') in ['L1', 'L2', 'L3'] or + states('input_select.marstek_m6_phase') in ['L1', 'L2', 'L3'] }} + # Strategy dynamic | is the avg tariff low enough to call this a 'cheap period' - name: "Is below threshold cheapest period tariff" unique_id: house_battery_strategy_dynamic_is_below_threshold_cheapest_tariff diff --git a/home assistant/packages/house_battery_control_config.yaml b/home assistant/packages/house_battery_control_config.yaml index 7572993..8385ef5 100644 --- a/home assistant/packages/house_battery_control_config.yaml +++ b/home assistant/packages/house_battery_control_config.yaml @@ -36,3 +36,28 @@ template: unit_of_measurement: "W" device_class: "power" state_class: "measurement" + + # House battery control | Optional per-phase grid power + # Uncomment these aliases if your three-phase meter exposes net power per phase. + # Positive = import from grid, negative = export to grid. + # Leave these blocks commented out if you do not have a three-phase setup. + # - name: "P1 Meter L1 Power" + # unique_id: "p1_meter_l1_power" + # state: "{{ states('sensor.your_l1_phase_power_sensor') }}" # << SET YOUR L1 PHASE POWER SENSOR HERE + # unit_of_measurement: "W" + # device_class: "power" + # state_class: "measurement" + + # - name: "P1 Meter L2 Power" + # unique_id: "p1_meter_l2_power" + # state: "{{ states('sensor.your_l2_phase_power_sensor') }}" # << SET YOUR L2 PHASE POWER SENSOR HERE + # unit_of_measurement: "W" + # device_class: "power" + # state_class: "measurement" + + # - name: "P1 Meter L3 Power" + # unique_id: "p1_meter_l3_power" + # state: "{{ states('sensor.your_l3_phase_power_sensor') }}" # << SET YOUR L3 PHASE POWER SENSOR HERE + # unit_of_measurement: "W" + # device_class: "power" + # state_class: "measurement" diff --git a/node-red/01 start-flow.json b/node-red/01 start-flow.json index 3f899e2..d5b19fe 100644 --- a/node-red/01 start-flow.json +++ b/node-red/01 start-flow.json @@ -317,11 +317,15 @@ "f6f3ce29833ec4cf", "5ac3cdf0ad6013e3", "69d4aa99b46127ba", - "783deb36dab080de" + "783deb36dab080de", + "9eaf5b01ad50102c", + "9eaf5b01ad50102d", + "9eaf5b01ad50102e", + "9eaf5b01ad50102f" ], "x": 34, "y": 999, - "w": 752, + "w": 1642, "h": 82 }, { @@ -675,7 +679,7 @@ "z": "cf69560481408644", "g": "98643475a97c956a", "name": "Mapping", - "func": "// Map to batteries array\nmsg.batteries.push({\n id: `M${msg.battery_index}`,\n power: parseInt(msg.battery_power,10),\n charging_max: parseInt(msg.max_charge_power,10),\n discharging_max: parseInt(msg.max_discharge_power,10),\n soc: parseInt(msg.battery_state_of_charge,10),\n soc_max: parseInt(msg.charging_cutoff_capacity,10),\n soc_min: parseInt(msg.discharging_cutoff_capacity,10),\n inverter: String(msg.inverter_state),\n energy: Number(msg.battery_remaining_capacity),\n energy_max: Number(msg.battery_total_energy),\n rs485: String(msg.rs485_control_mode)\n });\n\n// Continue\nreturn msg;", + "func": "// Normalize optional phase assignment\nconst normalizedPhase = String(msg.battery_phase || \"\").trim().toUpperCase();\nconst phase = [\"L1\", \"L2\", \"L3\"].includes(normalizedPhase) ? normalizedPhase : \"unassigned\";\n\n// Map to batteries array\nmsg.batteries.push({\n id: `M${msg.battery_index}`,\n phase: phase,\n power: parseInt(msg.battery_power,10),\n charging_max: parseInt(msg.max_charge_power,10),\n discharging_max: parseInt(msg.max_discharge_power,10),\n soc: parseInt(msg.battery_state_of_charge,10),\n soc_max: parseInt(msg.charging_cutoff_capacity,10),\n soc_min: parseInt(msg.discharging_cutoff_capacity,10),\n inverter: String(msg.inverter_state),\n energy: Number(msg.battery_remaining_capacity),\n energy_max: Number(msg.battery_total_energy),\n rs485: String(msg.rs485_control_mode)\n });\n\n// Continue\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -1932,7 +1936,7 @@ "y": 500, "wires": [ [ - "43bc77e1a735d1d4" + "9eaf5b01ad50102a" ] ] }, @@ -1942,7 +1946,7 @@ "z": "cf69560481408644", "g": "98643475a97c956a", "name": "Loop end", - "func": "// cleanup \ndelete msg.battery_index;\n\ndelete msg.battery_power;\ndelete msg.max_charge_power;\ndelete msg.max_discharge_power;\ndelete msg.battery_state_of_charge;\ndelete msg.charging_cutoff_capacity;\ndelete msg.discharging_cutoff_capacity;\ndelete msg.inverter_state;\ndelete msg.battery_remaining_capacity;\ndelete msg.battery_total_energy;\ndelete msg.rs485_control_mode;\n\nreturn msg;", + "func": "// cleanup \ndelete msg.battery_index;\n\ndelete msg.battery_power;\ndelete msg.max_charge_power;\ndelete msg.max_discharge_power;\ndelete msg.battery_state_of_charge;\ndelete msg.charging_cutoff_capacity;\ndelete msg.discharging_cutoff_capacity;\ndelete msg.inverter_state;\ndelete msg.battery_remaining_capacity;\ndelete msg.battery_total_energy;\ndelete msg.rs485_control_mode;\ndelete msg.battery_phase;\ndelete msg.template;\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -3262,7 +3266,7 @@ "y": 1040, "wires": [ [ - "f7e09807696115eb" + "9eaf5b01ad50102c" ] ] }, @@ -3567,5 +3571,131 @@ "modules": { "node-red-contrib-home-assistant-websocket": "0.80.3" } + }, + { + "id": "9eaf5b01ad50102a", + "type": "function", + "z": "cf69560481408644", + "g": "2d7842e57b2a0b38", + "name": "Phase template", + "func": "// Prepare optional battery phase helper lookup\nmsg.template = `{{ states('input_select.marstek_m${msg.battery_index}_phase') }}`;\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1760, + "y": 500, + "wires": [ + [ + "9eaf5b01ad50102b" + ] + ] + }, + { + "id": "9eaf5b01ad50102b", + "type": "api-render-template", + "z": "cf69560481408644", + "g": "2d7842e57b2a0b38", + "name": "Phase", + "server": "176d29a.6f648d6", + "version": 0, + "template": "", + "resultsLocation": "battery_phase", + "resultsLocationType": "msg", + "templateLocation": "", + "templateLocationType": "none", + "x": 1920, + "y": 500, + "wires": [ + [ + "43bc77e1a735d1d4" + ] + ] + }, + { + "id": "9eaf5b01ad50102c", + "type": "api-render-template", + "z": "cf69560481408644", + "g": "80405532eb3f52d4", + "name": "Max phase power", + "server": "176d29a.6f648d6", + "version": 0, + "template": "{{ states('input_number.house_battery_control_max_phase_power') | float(5500) }}", + "resultsLocation": "grid_power_limit_phase", + "resultsLocationType": "msg", + "templateLocation": "", + "templateLocationType": "none", + "x": 880, + "y": 1040, + "wires": [ + [ + "9eaf5b01ad50102d" + ] + ] + }, + { + "id": "9eaf5b01ad50102d", + "type": "function", + "z": "cf69560481408644", + "g": "80405532eb3f52d4", + "name": "Normalize phase limit", + "func": "const phaseLimit = Number(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nRED.util.setMessageProperty(\n msg,\n \"grid_power_limit_phase\",\n Number.isFinite(phaseLimit) && phaseLimit > 0 ? phaseLimit : 5500,\n true\n);\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1080, + "y": 1040, + "wires": [ + [ + "9eaf5b01ad50102e" + ] + ] + }, + { + "id": "9eaf5b01ad50102e", + "type": "api-render-template", + "z": "cf69560481408644", + "g": "80405532eb3f52d4", + "name": "Phase grid power", + "server": "176d29a.6f648d6", + "version": 0, + "template": "{%- macro phase_power(entity_id) -%}\n{%- set raw = states(entity_id) -%}\n{%- if raw in ['unknown', 'unavailable', 'none', 'None', ''] -%}\nnull\n{%- elif is_number(raw) -%}\n{{ raw | float }}\n{%- else -%}\nnull\n{%- endif -%}\n{%- endmacro -%}\n{\"L1\": {{ phase_power('sensor.p1_meter_l1_power') }}, \"L2\": {{ phase_power('sensor.p1_meter_l2_power') }}, \"L3\": {{ phase_power('sensor.p1_meter_l3_power') }}}", + "resultsLocation": "grid_power_phase", + "resultsLocationType": "msg", + "templateLocation": "", + "templateLocationType": "none", + "x": 1290, + "y": 1040, + "wires": [ + [ + "9eaf5b01ad50102f" + ] + ] + }, + { + "id": "9eaf5b01ad50102f", + "type": "function", + "z": "cf69560481408644", + "g": "80405532eb3f52d4", + "name": "Normalize phase power", + "func": "let phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\");\n\nif (typeof phasePower === \"string\") {\n const rawPhasePower = phasePower.trim();\n if (rawPhasePower.length > 0) {\n try {\n phasePower = JSON.parse(rawPhasePower);\n } catch (error) {\n phasePower = {};\n }\n } else {\n phasePower = {};\n }\n}\n\nif (!phasePower || typeof phasePower !== \"object\") {\n phasePower = {};\n}\n\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\n\nRED.util.setMessageProperty(msg, \"grid_power_phase\", {\n L1: toNumberOrNull(phasePower.L1 ?? phasePower.l1),\n L2: toNumberOrNull(phasePower.L2 ?? phasePower.l2),\n L3: toNumberOrNull(phasePower.L3 ?? phasePower.l3),\n}, true);\n\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1510, + "y": 1040, + "wires": [ + [ + "f7e09807696115eb" + ] + ] } -] \ No newline at end of file +] diff --git a/node-red/all-flows-in-one-file.json b/node-red/all-flows-in-one-file.json index 2ffe90b..52e2c6c 100644 --- a/node-red/all-flows-in-one-file.json +++ b/node-red/all-flows-in-one-file.json @@ -1342,11 +1342,15 @@ "a6203334d934a28a", "d8d5fbfc33432345", "783d88edb917dab9", - "f496519e76d1c4fa" + "f496519e76d1c4fa", + "9eaf5b01ad50102c", + "9eaf5b01ad50102d", + "9eaf5b01ad50102e", + "9eaf5b01ad50102f" ], "x": 34, "y": 999, - "w": 752, + "w": 1642, "h": 82 }, { @@ -3109,7 +3113,7 @@ "z": "419f395a5f52024b", "g": "a7c766e9d6b9fce6", "name": "Mapping", - "func": "// Map to batteries array\nmsg.batteries.push({\n id: `M${msg.battery_index}`,\n power: parseInt(msg.battery_power,10),\n charging_max: parseInt(msg.max_charge_power,10),\n discharging_max: parseInt(msg.max_discharge_power,10),\n soc: parseInt(msg.battery_state_of_charge,10),\n soc_max: parseInt(msg.charging_cutoff_capacity,10),\n soc_min: parseInt(msg.discharging_cutoff_capacity,10),\n inverter: String(msg.inverter_state),\n energy: Number(msg.battery_remaining_capacity),\n energy_max: Number(msg.battery_total_energy),\n rs485: String(msg.rs485_control_mode)\n });\n\n// Continue\nreturn msg;", + "func": "// Normalize optional phase assignment\nconst normalizedPhase = String(msg.battery_phase || \"\").trim().toUpperCase();\nconst phase = [\"L1\", \"L2\", \"L3\"].includes(normalizedPhase) ? normalizedPhase : \"unassigned\";\n\n// Map to batteries array\nmsg.batteries.push({\n id: `M${msg.battery_index}`,\n phase: phase,\n power: parseInt(msg.battery_power,10),\n charging_max: parseInt(msg.max_charge_power,10),\n discharging_max: parseInt(msg.max_discharge_power,10),\n soc: parseInt(msg.battery_state_of_charge,10),\n soc_max: parseInt(msg.charging_cutoff_capacity,10),\n soc_min: parseInt(msg.discharging_cutoff_capacity,10),\n inverter: String(msg.inverter_state),\n energy: Number(msg.battery_remaining_capacity),\n energy_max: Number(msg.battery_total_energy),\n rs485: String(msg.rs485_control_mode)\n });\n\n// Continue\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -4366,7 +4370,7 @@ "y": 500, "wires": [ [ - "fea927e3388ed2c9" + "9eaf5b01ad50102a" ] ] }, @@ -4376,7 +4380,7 @@ "z": "419f395a5f52024b", "g": "a7c766e9d6b9fce6", "name": "Loop end", - "func": "// cleanup \ndelete msg.battery_index;\n\ndelete msg.battery_power;\ndelete msg.max_charge_power;\ndelete msg.max_discharge_power;\ndelete msg.battery_state_of_charge;\ndelete msg.charging_cutoff_capacity;\ndelete msg.discharging_cutoff_capacity;\ndelete msg.inverter_state;\ndelete msg.battery_remaining_capacity;\ndelete msg.battery_total_energy;\ndelete msg.rs485_control_mode;\n\nreturn msg;", + "func": "// cleanup \ndelete msg.battery_index;\n\ndelete msg.battery_power;\ndelete msg.max_charge_power;\ndelete msg.max_discharge_power;\ndelete msg.battery_state_of_charge;\ndelete msg.charging_cutoff_capacity;\ndelete msg.discharging_cutoff_capacity;\ndelete msg.inverter_state;\ndelete msg.battery_remaining_capacity;\ndelete msg.battery_total_energy;\ndelete msg.rs485_control_mode;\ndelete msg.battery_phase;\ndelete msg.template;\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5696,7 +5700,7 @@ "y": 1040, "wires": [ [ - "2c2ed88ff158d0b7" + "9eaf5b01ad50102c" ] ] }, @@ -16037,5 +16041,131 @@ "x": 165, "y": 320, "wires": [] + }, + { + "id": "9eaf5b01ad50102a", + "type": "function", + "z": "419f395a5f52024b", + "g": "261b10d232e1c04e", + "name": "Phase template", + "func": "// Prepare optional battery phase helper lookup\nmsg.template = `{{ states('input_select.marstek_m${msg.battery_index}_phase') }}`;\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1760, + "y": 500, + "wires": [ + [ + "9eaf5b01ad50102b" + ] + ] + }, + { + "id": "9eaf5b01ad50102b", + "type": "api-render-template", + "z": "419f395a5f52024b", + "g": "261b10d232e1c04e", + "name": "Phase", + "server": "176d29a.6f648d6", + "version": 0, + "template": "", + "resultsLocation": "battery_phase", + "resultsLocationType": "msg", + "templateLocation": "", + "templateLocationType": "none", + "x": 1920, + "y": 500, + "wires": [ + [ + "fea927e3388ed2c9" + ] + ] + }, + { + "id": "9eaf5b01ad50102c", + "type": "api-render-template", + "z": "419f395a5f52024b", + "g": "79e4b4f8fef66565", + "name": "Max phase power", + "server": "176d29a.6f648d6", + "version": 0, + "template": "{{ states('input_number.house_battery_control_max_phase_power') | float(5500) }}", + "resultsLocation": "grid_power_limit_phase", + "resultsLocationType": "msg", + "templateLocation": "", + "templateLocationType": "none", + "x": 880, + "y": 1040, + "wires": [ + [ + "9eaf5b01ad50102d" + ] + ] + }, + { + "id": "9eaf5b01ad50102d", + "type": "function", + "z": "419f395a5f52024b", + "g": "79e4b4f8fef66565", + "name": "Normalize phase limit", + "func": "const phaseLimit = Number(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nRED.util.setMessageProperty(\n msg,\n \"grid_power_limit_phase\",\n Number.isFinite(phaseLimit) && phaseLimit > 0 ? phaseLimit : 5500,\n true\n);\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1080, + "y": 1040, + "wires": [ + [ + "9eaf5b01ad50102e" + ] + ] + }, + { + "id": "9eaf5b01ad50102e", + "type": "api-render-template", + "z": "419f395a5f52024b", + "g": "79e4b4f8fef66565", + "name": "Phase grid power", + "server": "176d29a.6f648d6", + "version": 0, + "template": "{%- macro phase_power(entity_id) -%}\n{%- set raw = states(entity_id) -%}\n{%- if raw in ['unknown', 'unavailable', 'none', 'None', ''] -%}\nnull\n{%- elif is_number(raw) -%}\n{{ raw | float }}\n{%- else -%}\nnull\n{%- endif -%}\n{%- endmacro -%}\n{\"L1\": {{ phase_power('sensor.p1_meter_l1_power') }}, \"L2\": {{ phase_power('sensor.p1_meter_l2_power') }}, \"L3\": {{ phase_power('sensor.p1_meter_l3_power') }}}", + "resultsLocation": "grid_power_phase", + "resultsLocationType": "msg", + "templateLocation": "", + "templateLocationType": "none", + "x": 1290, + "y": 1040, + "wires": [ + [ + "9eaf5b01ad50102f" + ] + ] + }, + { + "id": "9eaf5b01ad50102f", + "type": "function", + "z": "419f395a5f52024b", + "g": "79e4b4f8fef66565", + "name": "Normalize phase power", + "func": "let phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\");\n\nif (typeof phasePower === \"string\") {\n const rawPhasePower = phasePower.trim();\n if (rawPhasePower.length > 0) {\n try {\n phasePower = JSON.parse(rawPhasePower);\n } catch (error) {\n phasePower = {};\n }\n } else {\n phasePower = {};\n }\n}\n\nif (!phasePower || typeof phasePower !== \"object\") {\n phasePower = {};\n}\n\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\n\nRED.util.setMessageProperty(msg, \"grid_power_phase\", {\n L1: toNumberOrNull(phasePower.L1 ?? phasePower.l1),\n L2: toNumberOrNull(phasePower.L2 ?? phasePower.l2),\n L3: toNumberOrNull(phasePower.L3 ?? phasePower.l3),\n}, true);\n\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1510, + "y": 1040, + "wires": [ + [ + "2c2ed88ff158d0b7" + ] + ] } -] \ No newline at end of file +]