Energy management for Home Assistant: peak shaving, PV surplus routing and price-based optimisation for dynamic tariffs — with an Ingress dashboard, native Home Assistant entities, a Lovelace card and Bubble Card modules.
Status: pre-release. Everything described here is implemented and tested, but SunCore has not yet run against real hardware over a long period. It starts in dry run: it plans, shows you exactly what it would do, and writes nothing to your inverter or wallbox until you turn that off deliberately. Please leave it there for a few days before going live.
SunCore builds a 24-hour plan for your house every five minutes and executes the first step of it.
Peak shaving. Holds grid import below a limit you set, by discharging the battery or throttling loads. The limit is a soft constraint: when it physically cannot be met, the plan degrades and tells you, rather than failing.
PV surplus routing. Sends excess production to the highest-value sink in the order you choose — battery, then thermal storage, then the car. Implemented as a cost ladder inside the optimisation rather than a separate rule engine, so it never fights the price optimisation.
Price optimisation. Buys when electricity is cheap and avoids expensive quarter-hours, using Tibber, aWATTar, EPEX, Nord Pool or ENTSO-E. A fixed tariff is the default, because peak shaving and surplus routing are worth real money without any price spread — a dynamic contract is an addition, not a prerequisite.
SunCore is two pieces. Install both.
Settings → Add-ons → ⋮ → Repositories, add:
https://github.com/PXLJAKE/SunCore
Then install SunCore and start it. Open its panel and the setup wizard will look at your Home Assistant and propose everything it finds.
The repository has to be public for HACS. HACS reads
hacs.jsonandmanifest.jsonfromraw.githubusercontent.comwithout authentication, so a private repository fails its checks with messages about invalid files that are actually about visibility — and HACS could not install from it either way.
Install via HACS (Integrations → ⋮ → Custom repositories → this repository, category Integration), then restart Home Assistant.
You should then get a "SunCore discovered" notification — the add-on announces itself, so there is no host or port to type. Confirm it and you get the entities and the Lovelace card.
The card needs no resource entry: the integration serves and registers it, and it appears in the card picker as SunCore.
The wizard does the work. It shows every entity it found, how confident it is, and why it matched — so you can accept twelve suggestions with one button and still spot the one that is wrong.
Two things are worth your attention:
Check whether your price sensor already includes the fees. This is the setting most worth a minute of your attention, and the wizard asks it outright: "The sensor already includes all fees."
Compare one hour's price in Home Assistant against your supplier's app or your last bill.
| What you see | What to do |
|---|---|
| The sensor shows roughly what you actually pay (say 0.28 EUR/kWh) | Leave the switch on and leave the surcharge fields empty. SunCore uses the value as it is. |
| The sensor shows a bare market price (say 0.06 EUR/kWh, sometimes negative) | Turn the switch off and fill in your supplier markup, network charges, levies and VAT. |
Tibber publishes finished end-customer prices, so the switch defaults to on for it. aWATTar, EPEX, Nord Pool and ENTSO-E publish raw market prices, so it defaults to off — but treat both as guesses and check, because the markup can be configured inside several of those integrations, and a generic price sensor is whatever its author decided.
Getting this wrong is silent in both directions. Miss the surcharges and SunCore optimises against numbers twenty cents away from your bill. Add them on top of a Tibber price that already contains them and every hour looks expensive, so the battery looks permanently worth discharging and your house never charges it.
You can change this later without re-running setup: Settings → Tariff.
Pick your battery. SunCore needs its capacity and its charge and discharge limits before it can plan anything at all. The battery step has a picker covering common storage systems and hybrid inverters, so in most cases you choose two boxes and the numbers arrive filled in.
Pick the inverter as well as the pack. The inverter's AC side is usually what actually limits the house — a 12.8 kWh stack behind a 5 kW inverter does 5 kW — and SunCore takes whichever of the two is smaller. Every figure stays editable, because these are datasheet values and your installer may have configured something else.
Not listed? Choose Enter my own figures, or add it to
suncore/backend/hardware_catalog.yaml and
open a pull request — it is data, so no code changes and no release. You can also
drop your own hardware_catalog.yaml into the add-on's config folder.
Add whatever discovery missed. It recognises the integrations it knows, and that is never everything. At the bottom of the devices step — and later under Settings → Devices — you can add:
| Add this | For |
|---|---|
| EV charger | Any wallbox with a charging-current number, with or without a start/stop switch |
| Thermostat / air conditioning | Anything in the climate domain, used as thermal storage within a comfort band you set |
| Water heater / boiler | Anything in the water_heater domain |
| Switchable load | Any switch — a pool pump, a dehumidifier, a heating rod |
| Heat pump | Via an SG Ready block switch |
Each one asks which entities to use, filtered to the right domain. A device with no control entity mapped is read-only and says so rather than quietly not participating. For a car or a boiler, give it an energy target and a deadline — "8 kWh by 07:00" — and SunCore finds the cheapest way to get there. For a thermostat, give it a comfort band; SunCore moves the setpoint inside it and never outside, which is enforced in the safety layer rather than in the optimiser.
Set your connection rating under Limits. SunCore never plans above it. It is separate from the peak limit: the peak limit is a target you choose, the rating is physics.
Once you are satisfied with what dry run is showing:
Settings → Devices & services → SunCore → turn off Dry run, or flip
switch.suncore_dry_run.
Before you do, test the watchdog. Stop the add-on and confirm every device returns to its safe state. That is the behaviour standing between a modelling error and a battery that charges all night unattended, and it is worth verifying on your own hardware rather than trusting a test suite.
| Entity | For |
|---|---|
sensor.suncore_status |
What SunCore is doing; carries the plan as an attribute |
sensor.suncore_peak_shaving_limit |
Current limit |
sensor.suncore_grid_target_power |
Grid setpoint from the active plan |
sensor.suncore_current_price |
Price now; carries the forward curve |
sensor.suncore_battery_level / _power |
Battery state |
sensor.suncore_grid_power / _pv_power / _house_power |
Live flows |
sensor.suncore_saved_today |
Saved since midnight, or unknown if too little data |
number.suncore_grid_limit |
Grid limit — a slider in Bubble Card |
select.suncore_mode |
Automatic / peak shaving / price / off |
switch.suncore_peak_shaving / _surplus_routing / _dry_run |
Toggles |
button.suncore_re_plan_now / _stop_and_make_safe |
Actions |
binary_sensor.suncore_grid_limit_exceeded |
Pop-up trigger, automations |
binary_sensor.suncore_setpoint_refused |
A device ignored a setpoint |
binary_sensor.suncore_plan_stale |
The plan expired; devices are in safe state |
See docs/bubble-card.md for the four modules, ready-made dashboard YAML, and how to embed the card in a pop-up.
Short version: turn on Install Bubble Card modules in the integration's options and SunCore writes them wherever your Bubble Card keeps modules, without ever overwriting something you edited.
Hardware support is data, not code — each device is a YAML profile describing how to
recognise it, what to read, what to write and what its safe state is. Adding hardware is
a pull request against
suncore/backend/device_profiles/, not a release.
Inverters and storage: Kostal Plenticore, SolarEdge StorEdge, Fronius GEN24, Sungrow SH, Huawei SUN2000/LUNA2000, SMA, Victron ESS, Deye/Sunsynk, SolaX, GoodWe, plus monitoring for BYD, Pylontech and BSLBATT packs.
Wallboxes: evcc, Kostal ENECTOR, OCPP, go-e, Easee, KEBA, Wallbox Pulsar, Tesla.
Thermal: anything in the climate or water_heater domain. Heat pumps are
monitored by default and only controlled where a real setpoint entity exists.
Anything else: the generic_* profiles let you map entities by hand, which covers
every device Home Assistant can already control. That is the normal path, not a
fallback.
Check, do not take our word for it:
python scripts/backtest.py --database /path/to/suncore.db --days 7The backtester replays your own recorded history through the optimiser, giving it only the data that existed at each step — no peeking at the rest of the day — and compares against the same house doing what it would do without SunCore: battery on plain self-consumption, deferrable loads starting immediately.
It reports both cost and peak, and says which one actually moved. On a sunny day with a comfortable peak the honest answer is often "almost nothing in money, 1.4 kW off the peak", because ordinary self-consumption already captures most of the money. The tool says so rather than dressing it up.
Try it without any history using --synthetic.
┌──────────────────────── Home Assistant ────────────────────────┐
│ │
│ ┌─── Add-on: SunCore ──────────────────┐ ┌── Integration ──┐ │
│ │ │ │ (HACS) │ │
│ │ ha_client ──── websocket ───────────┼───┤ entities │ │
│ │ │ subscribe_entities │ │ suncore-card │ │
│ │ ▼ (deltas, not polling) │ │ bubble modules │ │
│ │ discovery ──▶ device_profiles/*.yaml│ └────────┬────────┘ │
│ │ │ │ │ │
│ │ ▼ │ Supervisor discovery │
│ │ adapters ──▶ prices + PV forecast │◀───────────┘ │
│ │ │ │ │
│ │ ▼ │ │
│ │ optimizer (MILP, 96 × 15 min) │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ safety ──▶ actuators ──── REST ─────┼──▶ inverter, wallbox, │
│ │ (clamps, watchdog) service calls │ climate, boiler │
│ │ │ │ │
│ │ ▼ │ │
│ │ FastAPI ──▶ SSE ──▶ Vue 3 dashboard │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
The optimiser is a MILP, not an LP. Devices that are genuinely on or off, minimum run times and semi-continuous loads — an EV charges at 6 A or not at all — need binary variables. Two things that look like they need them do not: battery charge/discharge exclusivity is already dominated by round-trip losses, and "no grid charging" is expressible as grid import never exceeds consumption, which is linear.
Both grid limits are soft. A hard peak limit plus an empty battery plus non-throttleable base load has no solution, and then there is no plan at all — precisely when one is needed. The connection rating is soft for the same reason: 8 kW of load on a 6 kW connection trips the breaker either way, and SunCore's job is to keep planning and report it.
The solver is resolved at runtime. PuLP 4 publishes no armv7 wheel and replaced its
API, so the project is pinned below 4.0; highspy has no armv7 wheel either. So on amd64
and aarch64 SunCore uses HiGHS, and on armv7 it uses the coinor-cbc package the image
installs. GET /api/solver reports which one a given install actually got, and the image
build fails if there is none.
Safety is separate from optimisation. Hard clamps, rate limits, a setpoint deadband,
read-back verification and a watchdog live above the optimiser in safety.py. A
modelling mistake must not be able to reach hardware, and an inverter that silently
ignores a Modbus write must not look like a success.
Runtime config is not add-on options. /data/options.json belongs to the Supervisor;
writing it needs POST /addons/self/options and restarts the add-on. SunCore keeps its
configuration in /data/config.json and its history in /data/suncore.db.
# Backend
pip install -e ".[dev]"
uvicorn backend.main:app --reload --port 8099 --app-dir suncore
# Dashboard
npm --prefix suncore/frontend ci
npm --prefix suncore/frontend run gen:api
npm --prefix suncore/frontend run dev
# Lovelace card (output goes into the integration)
npm --prefix lovelace/suncore-card ci
npm --prefix lovelace/suncore-card run buildEvery check CI runs, in one command:
bash scripts/check.shAfter changing models.py or main.py, regenerate the contract — CI fails on a stale
one, because the frontend's types are generated from it:
python scripts/export_openapi.py
npm --prefix suncore/frontend run gen:apiA .devcontainer/ is provided; it installs CBC so development uses the same solver path
as production.
| Path | Contents |
|---|---|
suncore/ |
The add-on: FastAPI backend, optimiser, Vue dashboard |
suncore/backend/models.py |
Single source of truth for every wire format |
suncore/backend/device_profiles/ |
Declarative hardware support |
custom_components/suncore/ |
HACS integration, built card, Bubble modules |
lovelace/suncore-card/ |
Lit source of the Lovelace card |
scripts/backtest.py |
Replays your history to measure the saving |
suncore/openapi.json |
Generated contract; the dashboard's types come from it |
Do I need a dynamic tariff? No. A fixed price is the default, and peak shaving and surplus routing work without any price spread.
My Tibber prices already include the fees — what do I fill in? Nothing. Leave the surcharge fields empty and leave "the sensor already includes all fees" on, which is the default for Tibber. Anything you type into those fields would be added on top of a price that already contains it. See Setup.
How often does SunCore re-plan? The full optimisation runs every five minutes over the next 24 hours, and only its first step is executed — so each plan is acted on for at most five minutes before being replaced by one built on newer prices, a newer forecast and the actual state of the battery. Between those, a fast loop runs every two seconds: it reads your meters, updates the dashboard, holds the peak limit and watches for faults. Optimisation does not need to run every second; reacting does.
Do I need a battery? No. Without one, SunCore shifts controllable loads and routes surplus. The dashboard and backtester both handle it.
Will it fight my inverter's own logic? Only where you map a write. Anything not mapped is monitored, not commanded, and the safe state returns every device to its own control.
What happens if the add-on crashes? Every plan carries an expiry. When it lapses — or Home Assistant becomes unreachable, or you press stop — every device returns to its profile's safe state, normally the inverter's own self-consumption mode.
Why is my saving smaller than I expected? Probably because it should be. Run the backtester: if the baseline already captures the value, that is worth knowing rather than being hidden behind an optimistic figure.
It says a setpoint was refused. Your device accepted the write and ignored it, which
inverters do when they are in the wrong operating mode. Check
binary_sensor.suncore_setpoint_refused and the actuation log on the Devices page.
SunCore controls inverters, battery storage and EV chargers. You remain responsible for complying with your grid operator's requirements and, in Germany, with §14a EnWG. Commissioning starts in dry run; nothing is written to your hardware until you turn that off.
Apache-2.0