Take battery control away from the inverter firmware and give it to Home Assistant.
This project holds a Solinteg / Reco inverter permanently in its
Charge-Discharge mode (EMS BattCtrl) and regulates the battery from a small
Home Assistant automation that runs every 5 seconds. It sits on top of the
Darkstar energy optimizer: Darkstar still
decides what to do (charge / discharge / self-consume / hold), but Home
Assistant decides exactly how many watts, in real time, with grid feedback.
The payoff: the inverter can no longer dump uncontrolled grid power during a cloud pass or evening ramp, you get tight zero-import self-consumption, and every part of the control law is plain Jinja you can read and tune.
⚠️ Before you install anything, read Assumptions & Disclaimers at the end of this document. It lists what must be true about your inverter, sensors and tariff for this to be safe. It's at the back so you can understand how it works first — but it is the most important section.
This is exactly what the reference install runs. Versions matter: the SolaX register rescale and the mode rename below are version-specific.
| Component | Version | Notes |
|---|---|---|
| Home Assistant Core | 2026.5.4 | |
| SolaX Modbus (HACS) | 2026.05.3 | exposes register 50207 as kW since this version |
| Darkstar add-on | v2.6.3-beta | uses only stock features — see portability |
| Inverter | Solinteg / Reco (20 kW class) | required — this guide is Solinteg/Reco only |
| Battery | HV ~390 V, ~32 kWh, 9 kW charge cap | your numbers differ — see migration |
| EV charger | Keba P30 (6–16 A) | optional / swappable |
| Grid meter | P1/HAN reader (~1 s) + inverter meter | any fast power sensor works |
Inverter scope: this guide assumes a Solinteg or Reco inverter. The inverter-side facts (register 50207,
EMS BattCtrl, polarity, SolaX settings) are the same for everyone. Everything around the inverter — battery, EV charger, grid meter, car — is swappable and covered in Migration.
A modern hybrid inverter has built-in "smart" modes (General / Economic / Self-Use…) where the firmware controls the battery autonomously. That's convenient, but:
- Darkstar's planning cycle is ~60 s. If a cloud rolls in, the firmware can start pulling several kW from the grid seconds after PV collapses — long before Darkstar's next tick can react.
- You cannot express "charge at exactly the rate that keeps grid import at zero" — the firmware decides, not you.
Always-CD fixes this by never using the autonomous modes. The inverter is parked
in EMS BattCtrl (a.k.a. Charge-Discharge / forced power) mode, where it does
exactly what the battery power register tells it. Home Assistant then writes that
register every 5 seconds based on live grid feedback. The firmware never makes a
decision again.
┌──────────────────────────────────────────────────────────────────────┐
│ Darkstar add-on (planner + executor, ~60 s cycle) │
│ decides MODE: charge | export | self_consumption | idle │
│ and a power recommendation in watts │
└───────────────┬──────────────────────────────────┬────────────────────┘
│ writes (via the Always-CD profile)│
▼ ▼
input_select.darkstar_active_mode input_number.darkstar_power_rec_w
("which mode") ("how many watts")
│ │
└───────────────┬────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ HA automation "Always-CD: Battery control loop" (every 5s)│
│ reads: mode, rec, grid power, PV, load, battery, SoC │
│ computes per-mode target_kw (I-regulator + clamps) │
│ writes: register_value = -target_kw │
└───────────────────────────┬─────────────────────────────┘
▼
number.reco_inverter_battery_charge_discharge_power_target
(Modbus register 50207, RAM-backed)
▼
Inverter charges / discharges
│
▼ (physical power flow)
┌──────── grid meter (P1/HAN, ~1 s) ────────┐
│ feeds sensor.darkstar_grid_power back │
└────────────────► closes the loop ◄─────────┘
Darkstar's executor only writes to helper entities here — it never touches the Modbus register. The 5 s automation owns the register. The inverter working mode (register 50000) is owned by the Darkstar profile + the startup automation; nothing else writes it. This separation is what keeps the whole thing stable.
Three clocks have to line up, fastest to slowest:
| Stage | Period | What it does |
|---|---|---|
| Grid meter (P1/HAN reader) | ~1 s | reports live grid import/export |
| SolaX Modbus fast poll | 4 s | refreshes register read-back + grid/PV/load into HA |
| Control automation | 5 s trigger + 2 s read-delay | waits for a fresh Modbus poll, then reads state, computes, writes register |
Stability rule: the Modbus fast poll must be faster than the automation period.
The automation fires every 5 s, then waits 2 s (a delay step at the top of the
automation) purely so a fresh 4 s Modbus poll has landed before it reads — register
read-back, grid, PV and load. It is not a "let the control loop settle" delay; the
loop has no inertia to settle. If the automation instead read immediately, it could act
on data from before its own previous write and oscillate.
It's tempting to set the automation to 4 s so it "syncs" with the Modbus poll. Don't — that's the opposite of what you want. The two are independent free-running timers; HA does not phase-lock them. Matching the periods doesn't align the events — it just freezes whatever random phase they started in (the beat frequency becomes zero). If that fixed phase puts the poll just after the read, then every read uses ~4 s-old data that predates the last command — the I-regulator corrects on stale state forever → locked-in oscillation. With 5 s ≠ 4 s the phase keeps drifting, so the most-recent poll before each read always lands after the previous write has settled → always fresh. The 1 s gap is the safety margin, not sloppiness. (If you want faster reaction, lower the Modbus poll to 2–3 s and keep the automation at 5 s — that increases the margin.)
Use your fastest meter as the grid source. A P1/HAN reader that updates every
second regulates dramatically better than the inverter's own meter (which only
updates at the Modbus poll interval). That's why sensor.darkstar_grid_power
defaults to the P1 reader — see examples/template_sensor.yaml.
The automation computes a target_kw (positive = charge, negative = discharge),
then encodes it to the register with the Solinteg polarity:
register_value = -target_kw # negative register = CHARGE, positive = DISCHARGE
Each mode has its own formula. grid_kw is import-positive; rec is Darkstar's
recommendation in kW; current_target_kw is the last value we wrote (read back from
the register). The charge cap (9 kW) and discharge cap (7.5 kW) are your battery's
limits — change them (see migration).
| Mode | Formula (kW) | Clamp |
|---|---|---|
| charge | I-regulator with a floor at Darkstar's rec: target = max(rec, current_target + (−grid_kw)) |
[0, 9.0] |
| export | target = −rec (discharge) |
[−7.5, 0] |
| self_consumption | bidirectional I-regulator toward grid=0: target = current_target + (−grid_kw) |
[−7.5, 9.0] |
| idle | if spot price < threshold: I-regulator toward grid=0 (charge only); else 0 |
[0, 9.0] |
Why an I-regulator? It has integrator memory. At grid_kw = 0 the step is
zero, so the setpoint stops moving — no oscillation. When PV surplus appears
(grid_kw < 0) the step is positive and the target organically rises above
Darkstar's recommendation to soak it up. See Troubleshooting
for the full rationale (it was hard-won).
Summer mode (input_boolean.darkstar_summer_mode): when ON, charge is remapped
to self_consumption, so the battery only absorbs PV surplus and discharges on a
cloud pass instead of charging from the grid at Darkstar's floor. Turn it OFF in
winter to let Darkstar grid-charge on cheap night tariff.
Assume battery charge cap 9 kW, discharge cap 7.5 kW, last register write −2.0
(so current_target_kw = +2.0, i.e. charging at 2 kW).
| Scenario | Inputs | Computation | target_kw |
Register | Result |
|---|---|---|---|---|---|
| charge, importing | mode=charge, rec=3, grid=+1.5 | max(3, 2 + (−1.5)) = max(3, 0.5) = 3 |
3.0 | −3.00 | hold at Darkstar's plan (3 kW) |
| charge, PV surplus | mode=charge, rec=3, grid=−2.0 | max(3, 2 + 2.0) = max(3, 4) = 4 |
4.0 | −4.00 | rises above rec to absorb surplus |
| self_consumption, load>PV | mode=sc, grid=+1.0 | 2 + (−1.0) = 1.0 (>0) |
1.0 | −1.00 | back off charging to kill import |
| self_consumption, big load | mode=sc, grid=+3.5, last=0 | 0 + (−3.5) = −3.5 (<0) |
−3.5 | +3.50 | discharge 3.5 kW to cover load |
| export | mode=export, rec=5 | −5, clamp [−7.5,0] |
−5.0 | +5.00 | discharge 5 kW to grid |
| idle, cheap + surplus | price<thr, grid=−1.2, last=0 | 0 + 1.2 = 1.2 |
1.2 | −1.20 | grab cheap solar surplus |
| idle, expensive | price≥thr | → 0 |
0.0 | 0.00 | battery holds |
You need all of these:
- ✅ A Solinteg or Reco inverter (this guide's polarity and register apply to them).
- ✅ The SolaX Modbus
integration, which exposes:
select.…_working_modewith anEMS BattCtrloption, andnumber.…_battery_charge_discharge_power_target(register 50207, RAM-backed — safe to write every few seconds without EEPROM wear).
- ✅ Darkstar running and able to load a custom
profile from
/config. The Always-CD profile uses only stock Darkstar features (charge_value/export_with_load_wtemplates,custom_entities), so it is independent of Darkstar version and works on the upstream release — no fork needed. - ✅ A reasonably fast grid power sensor (a 1 s P1/HAN reader is ideal).
If you have all four, the rest is just mapping entity IDs.
The inverter side is identical for every Solinteg/Reco user. What changes between installs is the hardware around the inverter. Substitute these.
| Role | Reference entity / service | Must provide | Change for your system? |
|---|---|---|---|
| Battery setpoint (reg 50207) | number.reco_inverter_battery_charge_discharge_power_target |
writable kW, neg=charge | only if your entity prefix differs |
| Working mode | select.reco_inverter_working_mode |
has EMS BattCtrl option |
only if prefix differs |
| PV power | sensor.reco_inverter_pv_power_total |
W | rename to yours |
| House load | sensor.reco_inverter_house_total_load |
W | rename to yours |
| Battery power | sensor.reco_inverter_battery_power |
W (+discharge/−charge) | rename to yours |
| Battery SoC | sensor.reco_inverter_battery_soc |
% | rename to yours |
| Grid power | sensor.darkstar_grid_power (template) |
W, import positive | point template at your meter |
| Spot price | sensor.nordpool_kwh_se3_sek_3_10_025 |
price/kWh | your price sensor |
| EV charger | keba.set_current / keba.enable / keba.disable |
current control | your charger, or delete EV automation |
| EV enable signal | input_boolean.keba_smart_charging |
on/off (Darkstar's EV schedule) | your Darkstar EV switch entity |
| EV plugged-in | binary_sensor.keba_p30_plug |
on/off | your charger |
| EV power | sensor.keba_p30_charging_power |
kW | your charger |
| Car SoC | sensor.tesla_soc |
% | your car (Darkstar config) |
| Notify | notify.… (Darkstar config) |
HA notify service | your device |
The Solinteg/Reco entities above mostly share the reco_inverter_ prefix — if yours
differ, a find-and-replace of the prefix across the example files is enough.
Solinteg/Reco register 50207 is inverted vs. the SolaX documentation: a negative value charges. Firmware/integration versions can drift, so verify on your unit before trusting the automation:
- Put the inverter in
EMS BattCtrl(run the startup automation or set it manually). - In Developer Tools → Actions, call
number.set_valueonnumber.…_battery_charge_discharge_power_targetwith-1.0. - Watch
sensor.…_battery_power/ SoC for ~30 s. It should charge (battery power goes negative / SoC trends up). If instead it discharges, your polarity is the opposite — flip the sign in the automation'sregister_valueline. - Set the value back to
0.
Do this at a moderate SoC (say 40–70 %) so neither direction hits a BMS limit.
The control automation clamps charge to 9.0 kW and discharge to 7.5 kW. These
are the reference battery's safe limits (BMS-derived). Edit them in
examples/automations.yaml — search for 9.0 and
-7.5 in the target_kw template and replace with your battery's continuous
charge/discharge power. The inverter firmware also clamps to the hardware limit, but
set sane software caps anyway.
The EV automation (#3) is optional. If you have no EV charger, delete the whole
ev_peak_tariff_control automation. To use a different charger, replace the three
keba.* calls with your charger's current (or power) control service, and swap the
Keba/Tesla sensors. The 6 A floor / 16 A ceiling and 230 V assumption are in the
ev_amps lines — adjust to your charger.
sensor.darkstar_grid_power (in
examples/template_sensor.yaml) is where you wire in
your meter. Keep the import = positive convention; multiply by -1 if your sensor
is inverted. Prefer your fastest-updating power sensor as the primary source.
-
Configure the SolaX Modbus integration (Settings → Devices & Services → SolaX Modbus → Configure):
Setting Value Interface TCP / Ethernet Modbus address 255 Inverter Type solinteg Default polling interval (s) 60 Medium polling interval (s) 60 Fast polling interval (s) 4 Name suffix Inverter Max inverter power (kW) 17 The 4 s fast poll is essential (see timing chain).
-
Install the Darkstar profile. Copy
profiles/solinteg-always-cd.yamlto/config/always-cd/profiles/and point Darkstar at it indarkstar/config.yaml:system: inverter_profile: /config/always-cd/profiles/solinteg-always-cd
Then restart the Darkstar add-on so its dispatcher picks up the custom entities.
-
Create the helpers from
examples/helpers.yaml(UI helpers, or paste intoconfiguration.yaml). -
Add the grid sensor from
examples/template_sensor.yamland reload template entities. Pick your grid sensor: setinput_select.darkstar_grid_sensor_primaryto your fastest meter. -
Add the automations from
examples/automations.yamland reload automations. Delete automation #3 if you have no EV charger. -
Run the polarity sanity-check above.
-
Set the inverter to
EMS BattCtrl(restart HA to fire the startup automation, or set it manually once).
Tip — test in shadow first. Set Darkstar's
executor.shadow_mode: true(or just watch with the automations disabled) for a day to confirm the modes and recommended watts look sane before letting it write the register.
With everything live, confirm each mode:
- Startup: after an HA restart,
select.…_working_modereadsEMS BattCtrl. - idle (expensive): set
darkstar_active_mode = idlewith price above threshold → register settles at0. - charge: Darkstar in charge, importing → battery charges around Darkstar's rec; with PV surplus the register rises above rec.
- self_consumption: vary load vs PV → grid power hovers near 0; register goes negative (charge) on surplus, positive (discharge) on deficit.
- export: mode=export, rec=5 kW → register ≈
+5.0(discharging). - No oscillation: watch
number.…_charge_discharge_power_targetfor a few minutes in steady conditions — it should be calm, not sawtoothing every tick.
Symptom: the register sawtooths every 5–15 s; grid oscillates ±1 kW. This is a feedback loop. The fixes that made it stable:
- Use integrator memory, not direct grid feedback. An early version computed
target = mode_target + (−grid_kw)from scratch each tick. Because the battery's own charging changesgrid_kw, that's an immediate closed loop with no memory → it oscillates aroundgrid_kw = 0. The I-regulator formtarget = current_target + (−grid_kw)remembers the last setpoint, so atgrid_kw = 0the step is zero and it stops moving. - Open-loop where you can.
self_consumptionoriginally triedtarget = −grid_kwand oscillated, because there control is bidirectional (charge and discharge) — the automation reacts in both directions and closes a loop. Computing the target from independent sensors (−(load − PV)), or using the bounded I-regulator, avoids it.idlecan safely use−grid_kwbecause it's unidirectional (charge-only): whengrid_kw → 0it switches off and settles. - Beat inverter transients with stable sensors. Solinteg inverters emit brief
(~4 s) bogus spikes on
house_total_loadright after the power register changes. Don't derive the setpoint fromhouse_total_load; regulate from the grid meter (P1/HAN), which is outside the inverter and unaffected.
Symptom: register won't change / battery idle when it should charge.
Check input_boolean.darkstar_enabled is on, the inverter is in EMS BattCtrl, and
the condition sensors aren't unavailable.
Symptom: it charges when it should discharge (or vice-versa).
Polarity. Re-run the sanity-check
and flip the sign in register_value.
Symptom: it oscillates only at high speed. Your Modbus fast poll is slower than the automation period. Set fast poll ≤ 4 s (and keep it below the automation's 5 s). Do not set the automation period equal to the poll period — keep it strictly greater, or you can lock in a stale-read oscillation (see Why 5 s and not 4 s).
- In
darkstar/config.yaml, setinverter_profileback to the stocksolintegprofile and restorecustom_entities.charge_discharge_power: number.reco_inverter_battery_charge_discharge_power_target. - Disable the three Always-CD automations.
- Restart the Darkstar add-on. Darkstar resumes writing the register directly on its next executor tick.
Read this before installing. This is community tinkering, provided as-is under the MIT license, with no warranty. It is not electrical, financial, or safety advice. You are responsible for your own hardware.
- Inverter scope. Written and tested for Solinteg / Reco only. The register
number (50207), the
EMS BattCtrlmode, and the polarity are specific to them. Do not assume another brand behaves the same. - Polarity is inverted vs. docs and can change across firmware/integration versions. Always run the sanity-check on your own unit first.
- RAM-backed register assumption. Frequent writes (every 5 s) are only safe because register 50207 is RAM-backed. If a future firmware made it EEPROM-backed, this would wear it out. The SolaX integration also de-duplicates writes via a threshold.
- Inverter transients. Solinteg units produce brief false readings on internal load sensors. The design avoids them by regulating from an external grid meter — keep it that way.
- No demand-tariff clamp by default. The reference system removed its peak/demand (effekt) tariff limiter when the local grid operator dropped that charge (2026-06-01). Charge is bounded only by the battery cap. If your grid operator charges a demand tariff, this code will not protect you from a peak — you'd need to re-add an import-budget clamp (the history is in the project notes; ask if you want it back).
- Safety is best-effort, in software. A control bug, a stuck sensor, or HA going down can leave the battery in an unexpected state. The BMS and inverter hardware limits are your real safety net; this layer is not a protection device.
- Test in shadow mode for a day before trusting it, and watch the first few cloud passes and evening ramps.
| File | Purpose |
|---|---|
profiles/solinteg-always-cd.yaml |
Darkstar inverter profile (redirects to HA helpers) |
examples/automations.yaml |
The 3 automations (startup, control loop, EV) |
examples/helpers.yaml |
input_number / input_select / input_boolean |
examples/template_sensor.yaml |
Configurable grid power sensor |
examples/dashboard.yaml |
Minimal Lovelace control card |
CHANGELOG.md |
How the approach evolved |
Built on top of Darkstar and SolaX Modbus. Thanks to both.