Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Always-CD — software-defined battery control for Home Assistant

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.


Software stack (reference system)

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.


What this does and why

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.


How it works

Architecture

 ┌──────────────────────────────────────────────────────────────────────┐
 │ 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.

The timing chain (why it's 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.

Why 5 s and not 4 s — don't match the periods

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.


Control algorithm

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.

Worked numeric examples

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

Does this fit my system?

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_mode with an EMS BattCtrl option, and
    • number.…_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_w templates, 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.


Migration guide

The inverter side is identical for every Solinteg/Reco user. What changes between installs is the hardware around the inverter. Substitute these.

Entity-mapping table

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.

Polarity sanity-check (do this once, before going live)

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:

  1. Put the inverter in EMS BattCtrl (run the startup automation or set it manually).
  2. In Developer Tools → Actions, call number.set_value on number.…_battery_charge_discharge_power_target with -1.0.
  3. 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's register_value line.
  4. Set the value back to 0.

Do this at a moderate SoC (say 40–70 %) so neither direction hits a BMS limit.

Adapting the battery

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.

Adapting / removing the EV charger

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.

Adapting the grid meter

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.


Installation

  1. 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).

  2. Install the Darkstar profile. Copy profiles/solinteg-always-cd.yaml to /config/always-cd/profiles/ and point Darkstar at it in darkstar/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.

  3. Create the helpers from examples/helpers.yaml (UI helpers, or paste into configuration.yaml).

  4. Add the grid sensor from examples/template_sensor.yaml and reload template entities. Pick your grid sensor: set input_select.darkstar_grid_sensor_primary to your fastest meter.

  5. Add the automations from examples/automations.yaml and reload automations. Delete automation #3 if you have no EV charger.

  6. Run the polarity sanity-check above.

  7. 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.


Verification

With everything live, confirm each mode:

  • Startup: after an HA restart, select.…_working_mode reads EMS BattCtrl.
  • idle (expensive): set darkstar_active_mode = idle with price above threshold → register settles at 0.
  • 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_target for a few minutes in steady conditions — it should be calm, not sawtoothing every tick.

Troubleshooting & control theory

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 changes grid_kw, that's an immediate closed loop with no memory → it oscillates around grid_kw = 0. The I-regulator form target = current_target + (−grid_kw) remembers the last setpoint, so at grid_kw = 0 the step is zero and it stops moving.
  • Open-loop where you can. self_consumption originally tried target = −grid_kw and 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. idle can safely use −grid_kw because it's unidirectional (charge-only): when grid_kw → 0 it switches off and settles.
  • Beat inverter transients with stable sensors. Solinteg inverters emit brief (~4 s) bogus spikes on house_total_load right after the power register changes. Don't derive the setpoint from house_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).


Rollback

  1. In darkstar/config.yaml, set inverter_profile back to the stock solinteg profile and restore custom_entities.charge_discharge_power: number.reco_inverter_battery_charge_discharge_power_target.
  2. Disable the three Always-CD automations.
  3. Restart the Darkstar add-on. Darkstar resumes writing the register directly on its next executor tick.

Assumptions & Disclaimers

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 BattCtrl mode, 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.

Files

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.

About

Software-defined battery control for Home Assistant: hold a Solinteg/Reco inverter in EMS BattCtrl and regulate from a 5s automation on top of Darkstar.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors