BYD Battery-Box Premium HVM + Fronius Gen24 (Modbus TCP)¶
Reference setup: BYD Battery-Box Premium HVM (12.8 kWh) controlled through a Fronius Gen24 inverter via Modbus TCP, Nordpool prices, Fronius SolarNet consumption sensor, Open-Meteo Solar Forecast.
Architecture¶
The BYD battery is not controlled directly — all control goes through the Fronius Gen24 via Modbus TCP (sunspec storage model):
Home Assistant ── modbus.write_register (hub: gen24, slave: 1)
└─ Fronius Gen24 ── internal BYD interface ── BYD Battery-Box Premium HVM
Modbus hub (configuration.yaml)¶
modbus:
- name: gen24
type: tcp
host: <inverter-ip>
port: 502
Home Assistant 2026.9 (Fronius Modbus in core)¶
From 2026.9 the built-in Fronius integration (Solar API) also talks Modbus TCP (SunSpec) when the inverter has it enabled. Existing config entries are migrated to port 502 automatically — no extra setup.
Keep the YAML hub and the scripts below. Do not drive the new core
number/switch entities (AC power limit, Battery charge/discharge power
limit, Battery minimum reserve, Battery grid charging) in parallel:
they write the same SunSpec registers (40232/40236, 40348–40356, 40360)
and will fight the SBP plan. Reading them is fine; they catch up on the
next Fronius poll (~1 min).
The core entities are limits and permissions, not force-charge / force-export. The negative (two's-complement) OutWRte/InWRte rates that force a charge or an export are not exposed, so these scripts cannot be replaced 1:1.
The Gen24 accepts only a handful of simultaneous Modbus TCP sessions
(YAML hub, core Fronius, often EVCC). After upgrading, check that
modbus.write_register still succeeds. Core claims it shares the
connection when host+port match; YAML hubs historically opened their own
socket — treat a second session as possible until proven otherwise.
Battery charging energy total / Battery discharging energy total from
core Fronius are DC counters on the device (no Riemann, survive restarts)
and are a better pair for SBP's optional actual-savings inputs than
integrating power.
Relevant Modbus registers (Fronius Gen24, slave 1)¶
| Register | Name | Description |
|---|---|---|
| 40345 | WChaMax | Maximum charge power in W (e.g. 12800), the base for both rates |
| 40348 | StorCtl_Mod | Bitfield that arms the rate limits: bit 0 = InWRte (charge limit), bit 1 = OutWRte (discharge limit). 0 none, 1 charge limit, 2 discharge limit, 3 both |
| 40350 | MinRsvPct | Minimum SOC reserve ×100 (500 = 5 %, 9900 = 99 %). The Gen24 tops a battery below it up from the grid, but only at ~500 W |
| 40355 | OutWRte | Discharge limit, % of WChaMax ×100 (10000 = 100 %, signed). 0 blocks discharging; a negative value (65536 − rate) forces charging at that rate. Needs bit 1 |
| 40356 | InWRte | Charge limit, % of WChaMax ×100 (10000 = 100 %, signed). 0 blocks charging; a negative value forces discharging at that rate. Needs bit 0 |
| 40358 | InOutWRte_RvrtTms | Revert timeout in seconds for InWRte/OutWRte. 0 = the limits stay until the next write; any other value drops them back to normal operation after that many seconds |
| 40360 | ChaGriSet | Grid charging: 1 = allowed (required for forced charging), 0 = PV only |
The rates only take effect while their bit is set in StorCtl_Mod. Measured on
the reference installation (night, 21 °C battery, 3000 W requested): StorCtl_Mod
1 with a negative OutWRte charged at only ~500 W (the MinRsvPct top-up),
StorCtl_Mod 2 charged at 3000 W. EVCC's Gen24 template uses the same
semantics (hold = StorCtl_Mod 2 + OutWRte 0; charge = StorCtl_Mod 2 +
negative OutWRte) and rewrites StorCtl_Mod when a car starts or stops
charging, which can end a running charge slot early.
Check InOutWRte_RvrtTms (40358) before you go live. The scripts below assume
0. Smart Battery Pilot runs the charge and export scripts at the start of
each slot, but the idle and auto scripts only when the mode or the reserve
changes. With a timeout of 900 s, a charge in hourly slots therefore stops
after 15 minutes, and in 15-minute slots the timeout races the next write.
The idle script below is not affected: it holds the battery through
MinRsvPct, which has no timeout. A hold built from StorCtl_Mod 2 and
OutWRte 0 instead expires after the timeout, and the battery discharges
for the rest of the idle block. Either write 0 to 40358, or have the
charge and export scripts write a timeout longer than one slot, which keeps
a safety net in case Home Assistant stops.
Scripts (scripts.yaml)¶
The charge and export scripts receive power_w from Smart Battery Pilot and
convert it to a rate in hundredths of a percent of WChaMax. Every script also
receives reserve_soc — the backup reserve, or the minimum SOC while none is
set. The auto script writes it into MinRsvPct (40350) so the Gen24 holds the
reserve on its own, even while Home Assistant is down; the idle script raises
MinRsvPct to the current SOC so the battery holds what it has. The charge
script keeps 99 % there on purpose. max_power is the value of WChaMax
(e.g. 12800 from register 40345, the first field of
sensor.reading_battery_settings).
sbp_force_charge:
alias: "SBP: Laden erzwingen"
fields:
power_w:
description: Charge power in W (provided by Smart Battery Pilot)
default: 6000
variables:
max_power: 12800
rate: "{{ [((power_w | default(6000)) / max_power * 10000) | int, 10000] | min }}"
sequence:
# Charge limit (InWRte) at 100 %; not armed by StorCtl_Mod 2 anyway
- service: modbus.write_register
data: { hub: gen24, slave: 1, address: 40356, value: 10000 }
# Negative discharge limit (OutWRte) = charge at least at this rate
- service: modbus.write_register
data: { hub: gen24, slave: 1, address: 40355, value: "{{ 65536 - rate }}" }
# Keep reserve at 99% so the battery keeps topping up (slowly) even if
# something resets StorCtl_Mod during the slot
- service: modbus.write_register
data: { hub: gen24, slave: 1, address: 40350, value: 9900 }
# Arm the discharge limit (bit 1). 1 would arm only the charge limit and
# leave the negative OutWRte without effect.
- service: modbus.write_register
data: { hub: gen24, slave: 1, address: 40348, value: 2 }
sbp_block_discharge:
alias: "SBP: Entladen sperren (Idle)"
sequence:
# No limits armed: PV may still charge, grid-charge from a previous
# `charge` slot must not continue.
- service: modbus.write_register
data: { hub: gen24, slave: 1, address: 40348, value: 0 }
- service: modbus.write_register
data: { hub: gen24, slave: 1, address: 40355, value: 10000 }
- service: modbus.write_register
data: { hub: gen24, slave: 1, address: 40356, value: 10000 }
# Hold the battery: MinRsvPct = current SOC (rounded down, so it never
# sits above the SOC and triggers a grid top-up), at least the backup
# reserve, at most 98 %. Always rewritten, so a preceding 99 % reserve
# cannot leak into idle hours.
# The SOC is the inverter's own ChaState (40351, seventh field of
# `sensor.reading_battery_settings`), with the battery's SOC sensor as
# fallback: see "Which SOC the idle script reads" below.
- service: modbus.write_register
data:
hub: gen24
slave: 1
address: 40350
value: >-
{%- set raw = states('sensor.reading_battery_settings').split(',') -%}
{%- set inv = (raw[6] | float(0)) / 100 if raw | length > 6 else 0 -%}
{%- set soc = (inv | int) if 0 < inv <= 100
else (states('sensor.byd_battery_box_premium_hv_ladezustand') | int(5)) -%}
{{ [[soc, reserve_soc | default(5) | int] | max, 98] | min * 100 }}
sbp_auto_mode:
alias: "SBP: Auto-Modus"
sequence:
- service: modbus.write_register
data: { hub: gen24, slave: 1, address: 40348, value: 0 }
- service: modbus.write_register
data: { hub: gen24, slave: 1, address: 40355, value: 10000 }
# Backup reserve from Smart Battery Pilot (minimum SOC while none is set);
# the inverter keeps it even while Home Assistant is down.
- service: modbus.write_register
data: { hub: gen24, slave: 1, address: 40350, value: "{{ ((reserve_soc | default(5)) * 100) | int }}" }
- service: modbus.write_register
data: { hub: gen24, slave: 1, address: 40356, value: 10000 }
sbp_force_discharge:
alias: "SBP: Entladen ins Netz erzwingen (Export-Modus)"
fields:
power_w:
description: Discharge power in W (provided by Smart Battery Pilot)
default: 6000
variables:
max_power: 12800
rate: "{{ [((power_w | default(6000)) / max_power * 10000) | int, 10000] | min }}"
sequence:
# Discharge limit (OutWRte) at 100 %; not armed by StorCtl_Mod 1 anyway
- service: modbus.write_register
data: { hub: gen24, slave: 1, address: 40355, value: 10000 }
# Negative charge limit (InWRte) = discharge at least at this rate
- service: modbus.write_register
data: { hub: gen24, slave: 1, address: 40356, value: "{{ 65536 - rate }}" }
# Arm the charge limit (bit 0)
- service: modbus.write_register
data: { hub: gen24, slave: 1, address: 40348, value: 1 }
Which SOC the idle script reads¶
The hold is only as good as the SOC it is built from. A battery integration that polls once a minute still reports the old value when an idle slot follows a charge slot: the floor then lands a percent or more below the real SOC, and the battery discharges down to it during the first idle minutes — energy that was bought a moment ago. The inverter's own ChaState register updates every few seconds and is the value MinRsvPct is compared against anyway, so the script reads that one and only falls back to the battery's sensor when the register sensor has no usable value.
The pilot's battery discharges in idle slots check reports this kind of leak once it has seen it on several days.
EVCC next to the pilot¶
sbp_block_discharge holds the battery through MinRsvPct, which EVCC never
touches. The alternative, StorCtl_Mod 2 with OutWRte 0 (EVCC's hold),
blocks discharging just as well but is undone when EVCC rewrites StorCtl_Mod.
The reverse happens too: while EVCC holds the battery for a car (StorCtl_Mod
2, OutWRte 0), a pilot that keeps writing would fight it. With EVCC on the
same inverter, set the EV section to policy External with
sensor.evcc_battery_mode (the "[CODE]" sensor of the evcc integration,
disabled by default). The pilot then pauses during EVCC's hold and
re-applies its own mode afterwards (EV charging).
Set Planned EV sessions to evcc as well: the plan then shows EVCC's coming
holds ahead of time and plans no grid charge into them, since the pilot would
not be allowed to run it there anyway.
sbp_force_discharge follows from the register semantics above (negative
InWRte, the mirror image of the verified charge script) but has not been
tested on the reference installation. Try it in a short manual run before you
let the pilot export.
Integration configuration¶
| Config flow field | Entity / value |
|---|---|
| Price forecast entity | sensor.nordpool_kwh_ger_eur_3_10_019 (Nordpool HACS) |
| Price offset | 0.187 EUR/kWh (German grid fees + taxes) |
| SOC entity | sensor.byd_battery_box_premium_hv_ladezustand |
| Capacity | 12.8 kWh |
| Max charge / discharge power | 6000 / 6000 W |
| Min / Max SOC | 10 / 95 % |
| Efficiency | 90 % |
| Script: force charge | script.sbp_force_charge |
| Script: idle | script.sbp_block_discharge |
| Script: auto | script.sbp_auto_mode |
| Script: export (optional) | script.sbp_force_discharge |
| Consumption sensor | sensor.solarnet_leistung_verbrauch (W, Fronius SolarNet) |
| Temperature sensor | sensor.aussen_temperatur |
| PV forecast today / tomorrow | sensor.vorhersage_solarproduktion_gesamt_heute / …_morgen (Open-Meteo Solar Forecast) |
| Current PV power (optional) | e.g. sensor.solarnet_leistung_produktion — shown live on the card |
| Battery charge / discharge energy (optional) | cumulative kWh or Wh meters, both needed for the battery-benefit and pilot-savings sensors and for learning the cold-weather charge limit. Prefer the core Fronius DC counters Battery charging/discharging energy total (HA 2026.9+) over a Riemann sum |
Optional features in the options menu:
| Option | Entity / value |
|---|---|
| Cold-weather charging | on, with the BYD module/cell temperature sensor of your byd_hvs installation as battery temperature; defaults 10/20/50/80/100 % suit the LFP cells — check the BYD datasheet |
| Backup reserve | e.g. 20 %, optionally an input_number your storm-warning automation raises; block discharging is not needed, the Gen24 holds MinRsvPct itself |
Verification sensors¶
Watch these while testing (dry-run first!):
| Entity | Forced charge | Idle |
|---|---|---|
sensor.byd_storctl_mod |
2 |
0 |
sensor.byd_… OutWRte (40355) |
65536 − rate, shown as a negative % |
10000 |
sensor.byd_… MinRsvPct (40350) |
9900 (99 %) |
current SOC × 100, at least reserve_soc × 100 |
sensor.solarnet_ladeleistung |
≈ planned power_w |
0 from grid |
sensor.byd_battery_box_premium_hv_stromstarke_dc |
negative (charging) | ~0 discharge |
Notes¶
- The Gen24 occasionally ignores a single Modbus write. If you see this,
wrap the writes in a retry (
repeatwith astopon success). - Negative price handling (stopping PV production below −0.10 EUR/kWh via
registers 40232/40236) is independent of this integration and can coexist.
From HA 2026.9 the same registers are also
number.*_ac_power_limit+switch.*_ac_power_limiting; do not bind both. Core's AC power limit is inverter output, not a grid export cap, and a value below 10 % may put the Gen24 into standby.