Home Assistant custom integration that controls a Zendure SolarFlow 800 Pro over local MQTT. Combines dynamic zero-export regulation (against a Shelly 3EM Pro) with price-based grid charging (Tibber) and a solar-forecast skip (Forecast.Solar).
No cloud, no middleware — speaks directly to your local MQTT broker.
Reads live grid power from the Shelly 3EM Pro and adjusts the Zendure's outputLimit to keep the net flow near a configurable target (default 0 W).
With a Tibber API token, the plugin pulls the next 24 h of prices. During the N cheapest hours it switches the Zendure to Input mode and pulls power through inputLimit — but only if the spread between the current price and the day's max covers round-trip losses.
If the forecast_solar integration is installed, the plugin compares expected remaining production to the kWh still needed to reach target SOC. If the sun alone will fill the battery, no grid charging happens — even during a cheap window.
- Free-Charge — auto-charges whenever the current price is ≤ 0 ct/kWh, ignoring solar forecast and cheap-window logic.
- Manual-Charge — one-shot grid-charge override that auto-disables once target SOC is reached.
- Smart-Discharge — pauses the regulation during the cheapest hours so the battery is preserved for higher-priced hours. Never exports to grid.
- Zendure SolarFlow 800 Pro with local MQTT enabled in the device settings. The device must publish on topics like
Zendure/sensor/<SN>/...and listen on.../set. - Shelly 3EM Pro (or compatible) publishing
total_act_poweron<device_id>/events/rpc. - Both devices on the same MQTT broker as Home Assistant.
- (Optional) Tibber account + API token for price-based charging.
- (Optional) Forecast.Solar integration for the smart skip.
Required for Cheap-Charge / Free-Charge / Manual-Charge: the device's "On-grid Input Mode" must be enabled in the Zendure mobile app, set to your desired charge power (e.g. 800 or 1000 W). Without this app-side setting the firmware accepts the MQTT quartet (
acMode=Input,inputLimit=...) but never actually pulls from the grid — every status topic looks correct, yetgridInputPowerstays at 0.
- HACS → Integrations → ⋮ → Custom repositories
- Add
https://github.com/mavnezz/Zendure-Charge44as type Integration - Pick
Zendure-Charge44from the list and install - Restart Home Assistant
- Settings → Devices & Services → Add Integration → charge44
Copy custom_components/charge44/ into <HA_config>/custom_components/charge44/ and restart HA.
Three steps in the config flow:
- Devices — the plugin scans MQTT for 3 seconds and offers detected Zendure SNs and Shelly IDs as dropdowns. Manual entry stays available if your devices don't auto-detect.
- Tibber + solar forecast (both optional) — paste the API token, pick the solar sensor. Leave blank if you only want zero-export regulation.
- Tibber home — only shown when the account has multiple homes.
| Entity | Description |
|---|---|
sensor.charge44_grid_power |
Net grid flow (Shelly) |
sensor.charge44_grid_import / _export |
Positive-only import/export, for the Energy Dashboard |
sensor.charge44_battery_soc |
SOC % |
sensor.charge44_battery_charging / _discharging / _net_flow |
Battery flow (W) |
sensor.charge44_solar_input |
PV input |
sensor.charge44_output_to_home |
Zendure → home |
sensor.charge44_output_limit_device |
current Zendure setpoint |
sensor.charge44_regulation_setpoint / _error |
plugin's PI controller |
sensor.charge44_pack_state, _temperature |
state + temperature |
sensor.charge44_current_price |
current electricity price (ct/kWh) |
sensor.charge44_cheap_hour |
"yes"/"no" — is the current hour in the cheap window |
sensor.charge44_cheap_charge_active |
"on"/"off" — currently in a charging cycle |
sensor.charge44_next_cheap_window |
timestamp of the next cheap hour |
sensor.charge44_solar_forecast_remaining |
kWh of solar left today |
sensor.charge44_grid_charge_needed |
shortfall vs target SOC |
sensor.charge44_today_min_price / _max_price |
day min/max (ct/kWh) |
sensor.charge44_spread_now |
today's max minus current |
sensor.charge44_required_spread |
required spread (min-spread vs break-even) |
sensor.charge44_charge_profitable |
"yes"/"no" — would charging pay off |
| Entity | Default | Meaning |
|---|---|---|
number.charge44_target_soc |
80 % | upper bound for charging — friendly name SOC Max |
number.charge44_min_soc |
10 % | discharge floor — friendly name SOC Min |
switch.charge44_regulation— zero-export regulation on/off (friendly name 0-Regulation)switch.charge44_cheap_charge— automatic cheap-window charging (friendly name Charge Cheap)switch.charge44_charge_when_free— always charge when price ≤ 0 ct/kWh, ignoring solar forecast and cheap-window (only temperature and target-SOC guards still apply) (friendly name Charge Free)switch.charge44_manual_charge— immediate grid charge regardless of price; auto-disables when target SOC is reached. One-shot, doesn't survive an HA restart (friendly name Charge Manual)switch.charge44_smart_discharge— preserve battery during cheap hours (friendly name Discharge Smart)switch.charge44_contiguous_block— pick the cheapest contiguous N-hour block instead of the cheapest scattered N hours (config category)
error = grid_power - grid_bias
setpoint += error × Kp
setpoint = clamp(0, max_output)
→ Zendure/number/<SN>/outputLimit/set
Hysteresis: only publish when the change exceeds the deadzone (5 W) AND the last publish is ≥ 3 s old.
cheap_hour = current_price is in the cheapest N of the next 24 h
spread_now = today_max - current_price
break_even = current_price × (1 / efficiency - 1)
required_spread = max(min_spread_ct, break_even)
profitable = spread_now ≥ required_spread
needed_kwh = (target_soc - soc) / 100 × battery_capacity
forecast_kwh = Forecast.Solar sensor (kWh remaining today)
gap = max(0, needed_kwh - forecast_kwh)
charge if: cheap_charge_enabled
∧ cheap_hour
∧ profitable
∧ soc < target_soc
∧ gap > 0
or: charge_when_free ∧ current_price ≤ 0
∧ soc < target_soc
(solar forecast and cheap-window are ignored)
or: manual_charge ∧ soc < target_soc
(everything except temperature + target SOC is ignored)
On entering cheap-charge mode the plugin publishes:
Zendure/select/<SN>/acMode/set → "Input mode"
Zendure/number/<SN>/inputLimit/set → charge_power
On exit it reverses both. The zero-export regulation pauses while cheap-mode is active.
Zero-export regulation always covers the home load only — it never actively exports (with a Tibber feed-in tariff near 0 ct/kWh, exporting is pure loss). Smart-Discharge suspends the regulation during the cheapest hours so the battery is preserved for normal/expensive ones:
if smart_discharge_enabled ∧ cheap_hour:
outputLimit = 0 (battery idle, cheap grid covers the home)
else:
outputLimit = PI loop (battery covers home load, no export)
With Smart-Discharge OFF the regulation runs every hour — the battery always covers the home.
- Single instance only (
single_instance_allowed) - Tested on Zendure 800 Pro + Shelly 3EM Pro only; other devices with the same MQTT topic structure should work but aren't verified
- No options-flow yet; to change the Tibber token or forecast entity, remove the integration and re-add it
The decision logic is covered by pytest. Locally:
pip install pytest
pytest tests/ -vSuites:
tests/test_want_cheap_charge.py— decision matrix (temperature guard, SOC cap, free-charge override, manual-charge override, solar skip, cheap-charge gating)tests/test_compute_is_cheap.py— Tibber price evaluation (spread, break-even, top-N, contiguous-block mode, 15-minute slots)tests/test_regulation.py— PI loop + smart-discharge (safety blocks, deadzone, cheap-hour pause)tests/test_publish.py— MQTT topics + payloads (cheap-mode quartet, minSoc forwarding)
CI runs on every push and PR — see .github/workflows/tests.yml.
See GitHub Releases.
MIT