These examples are starting points for config.json or Docker
config/config.json. The template profile is intended for normal standalone
live control after real local values are configured and installation limits are
reviewed.
If required placeholders are still present, EMS forces safe mode: control
disabled, dry-run enabled, and hardware writes blocked. Set dry_run=true
manually when you want an explicit no-write validation run after configuration.
Use example IP addresses and serial numbers as placeholders only. Before unattended operation, enter real grid meter and Zendure values, review power and SOC limits, confirm battery and PV metadata, run first-run-checklist.md, and monitor the first live run.
Use this for standalone EMS operation without Home Assistant.
{
"ha": {
"enabled": false,
"control_enabled": false,
"url": "",
"token": ""
},
"system": {
"enabled": true,
"dry_run": false,
"simulation_mode": false,
"allow_hardware_writes": true,
"allow_state_reconciliation_writes": true,
"reconcile_ac_mode_on_start": true,
"reconcile_smart_mode": true,
"log_level": "info",
"max_total_power": 800,
"max_device_power": 800,
"deadband": 2,
"runtime_state_path": "data/runtime-state.json",
"min_output_limit": 35,
"loop_interval": 5,
"redistribute_clamped_power": true,
"pv_kwp_weighting": true,
"pv_charge_balance_enabled": true,
"pv_charge_balance_deadband_percent": 1,
"pv_charge_balance_full_bias_percent": 15,
"pv_charge_balance_strength": 0.7,
"battery_kwh_weighting": true,
"soc_reconcile_interval": 10
},
"dashboard": {
"enabled": true,
"host": "0.0.0.0",
"port": 8080,
"database_path": "data/ems_dashboard.sqlite",
"history_hours": 48,
"write_interval_seconds": 5
},
"energy_savings": {
"enabled": true,
"price_per_kwh": 0.0,
"currency": "EUR",
"max_sample_delta_seconds": 20,
"timezone": "Europe/Berlin"
},
"winter": {
"enabled": false,
"months": [10, 11, 12, 1, 2, 3],
"summer_min_soc": 15,
"winter_min_soc": 40,
"ramp_step_percent": 5,
"adjust_hour": 12,
"ac_charge_power": 200
},
"devices": [
{
"name": "WR1",
"ip": "192.168.1.100",
"sn": "YOUR_SN",
"smart_mode": 1,
"max_power": 800,
"pv_kwp": 1.0,
"pv_priority_factor": 1.0,
"battery_kwh": 1.0,
"min_soc": 15,
"max_soc": 100
}
],
"grid_meter": {
"type": "shelly",
"ip": "192.168.1.50"
}
}Change:
devices[0].ipdevices[0].sngrid_meter.type,grid_meter.ip, and Shellygrid_meter.channelsif neededpv_kwpbattery_kwhdashboard.portordashboard.database_pathonly when neededenergy_savings.price_per_kwhenergy_savings.currencyenergy_savings.timezoneonly when you do not want Europe/Berlin calendar days
Docker check:
docker compose exec ems python3 emsctl.py diagnoseNative Python validation:
python3 -B ems-solarflow-api-control.py --simulate --max-cycles 1
python3 -B ems-solarflow-api-control.py --preflightUse this when Home Assistant should receive EMS sensors and optionally provide runtime helper controls. The values below intentionally override some single-device template values for a two-inverter installation.
{
"ha": {
"enabled": true,
"control_enabled": true,
"url": "http://homeassistant.local:8123",
"token": "YOUR_TOKEN_HERE"
},
"system": {
"enabled": true,
"dry_run": false,
"simulation_mode": false,
"allow_hardware_writes": true,
"allow_state_reconciliation_writes": true,
"reconcile_ac_mode_on_start": true,
"reconcile_smart_mode": true,
"max_total_power": 1600,
"max_device_power": 800,
"runtime_state_path": "data/runtime-state.json",
"min_output_limit": 35,
"loop_interval": 5
},
"devices": [
{
"name": "WR1",
"ip": "192.168.1.100",
"sn": "YOUR_SN",
"smart_mode": 1,
"max_power": 800,
"pv_kwp": 1.0,
"pv_priority_factor": 1.0,
"battery_kwh": 1.0,
"min_soc": 15,
"max_soc": 100
},
{
"name": "WR2",
"ip": "192.168.1.101",
"sn": "YOUR_SN",
"smart_mode": 1,
"max_power": 800,
"pv_kwp": 1.0,
"pv_priority_factor": 1.0,
"battery_kwh": 1.0,
"min_soc": 15,
"max_soc": 100
}
],
"grid_meter": {
"type": "shelly",
"ip": "192.168.1.50"
}
}Change:
ha.urlha.token- both device IPs and serial numbers
grid_meter.type,grid_meter.ip, and Shellygrid_meter.channelsif needed- PV and battery metadata
Docker check:
docker compose exec ems python3 emsctl.py diagnoseNative Python validation:
python3 -B ems-solarflow-api-control.py --preflight
python3 -B ems-solarflow-api-control.py --duration 120This setup allows live telemetry reads and target calculation, but blocks Zendure hardware writes.
{
"system": {
"dry_run": true,
"allow_hardware_writes": true,
"allow_state_reconciliation_writes": true
}
}Docker check:
docker compose exec ems python3 emsctl.py diagnoseNative Python validation:
python3 -B ems-solarflow-api-control.py --preflight --dry-run
python3 -B ems-solarflow-api-control.py --dry-run --onceUse this optional variant when you want to block all Zendure writes until after manual validation.
{
"system": {
"dry_run": true,
"allow_hardware_writes": false,
"allow_state_reconciliation_writes": false
}
}This is not the normal template profile. It blocks both normal outputLimit
writes and state reconciliation writes until you change the flags back.
Docker check:
docker compose exec ems python3 emsctl.py diagnoseNative Python validation:
python3 -B ems-solarflow-api-control.py --preflight --dry-run
python3 -B ems-solarflow-api-control.py --dry-run --onceThis is the normal template policy for standalone operation after required placeholders are replaced, real local values are configured, and installation limits are reviewed.
{
"system": {
"dry_run": false,
"allow_hardware_writes": true,
"allow_state_reconciliation_writes": true,
"reconcile_ac_mode_on_start": true,
"reconcile_smart_mode": true
}
}This allows normal outputLimit writes and required regulation/state
reconciliation. Runtime AC mode intent is evaluated during the control loop,
but startup acMode reconciliation is conservative when telemetry is unknown.
ac_output maps to acMode=2; ac_input maps to acMode=1 and blocks normal
output regulation. An explicit runtime ac-mode output command can still write
acMode=2 when the reported mode is 0.
Manual runtime AC charging uses the same loop-owned reconciliation path:
python3 emsctl.py device WR1 ac-charge-power 200
python3 emsctl.py device WR1 ac-mode input
python3 emsctl.py device WR1 ac-mode outputac-charge-power stores ac_charge_power_w in runtime-state only. The
controller applies it as inputLimit on the next EMS loop while the role is
ac_input, and ignores it for hardware writes while the role is ac_output.
The defaults are intended to expose the main regulation features with minimal setup. They are not a universal safety profile; review device limits, SOC limits, grid meter readings, and installation-specific constraints for each installation.
The template enables EMS full-charge assist by default. Set enabled to
false in config.json to opt out:
{
"battery_full_charge_assist": {
"enabled": true,
"interval_days": 28,
"assist_window_days": 7,
"assist_start_soc": 80,
"force_time": "14:00",
"ac_charge_power": 200,
"enable_ac_charge_mode": true,
"state_database_path": "data/ems_state.sqlite"
}
}When enabled, EMS temporarily requests socSet=1000 and waits for firmware
socLimit == 1. AC-assisted charging reuses the runtime AC intent path; EMS
does not write firmware calibration properties.
Use Tasmota HTTP when a Tasmota smart meter reader exposes current power in
the Status 10 JSON response. power_path must match your local Tasmota JSON
keys.
SML-style payload path:
{
"grid_meter": {
"type": "tasmota_http",
"ip": "192.168.1.70",
"power_path": "StatusSNS.SML.Power_curr"
}
}OBIS-style key path:
{
"grid_meter": {
"type": "tasmota_http",
"url": "http://192.168.1.70/cm?cmnd=Status%2010",
"power_path": "StatusSNS.SM.16_7_0"
}
}Positive power means grid import. Negative power means export/feed-in when the meter reports signed values that way.
Use zendure_grid_meter_http when a Zendure grid meter is reachable on the LAN.
This is the recommended Zendure grid-meter connection and works for both a
Zendure D0 and a Smart Meter 3CT: both expose a flat numeric total_power at the
local REST endpoint http://<ip>/properties/report. No MQTT broker and no app
MQTT configuration are required, and current known firmware serves the endpoint
without authentication.
{
"grid_meter": {
"type": "zendure_grid_meter_http",
"ip": "192.168.1.50"
}
}The discovered HTTP port is preserved (default 80). Numeric total_power is the
functional read criterion, and its sign is used as reported by the device. The
per-phase fields a_aprt_power, b_aprt_power, and c_aprt_power are not
used to identify the model — a D0 reports the same fields — and meterType /
protocolType are not treated as proven D0 identifiers. The Admin may show
"Zendure Grid Meter via local HTTP" without claiming a specific model.
The older zendure_smartmeter_3ct_http type remains accepted as a
backward-compatible alias for the same client, so existing configs keep working.
Local HTTP (Example 6b) is preferred. Use Zendure SmartMeter D0 (MQTT) only as an
optional alternative when the D0 already publishes signed totalPower to an
existing broker. EMS subscribes as a client; it does not run a broker and never
publishes or writes values back to the meter. Only one central grid meter may
be active.
Preferred: reference a named broker profile with broker_ref so the connection
(host, port, TLS, credentials) lives once in zendure_mqtt.brokers and the broker
password is never duplicated into the grid_meter block:
{
"grid_meter": {
"type": "zendure_smartmeter_d0",
"mqtt": {
"broker_ref": "local_mqtt",
"topic": "Zendure/sensor/YOUR_D0_SERIAL/totalPower",
"payload_format": "number",
"max_age_seconds": 15
}
},
"zendure_mqtt": {
"brokers": {
"local_mqtt": {
"enabled": true,
"source": "local_mqtt",
"host": "192.168.1.10",
"port": 1883,
"tls": false,
"username": "YOUR_MQTT_USER",
"password": "YOUR_MQTT_PASSWORD"
}
}
}
}The legacy inline form is still accepted so existing configs keep working:
{
"grid_meter": {
"type": "zendure_smartmeter_d0",
"mqtt": {
"host": "192.168.1.10",
"port": 1883,
"username": "YOUR_MQTT_USER",
"password": "YOUR_MQTT_PASSWORD",
"topic": "Zendure/sensor/YOUR_D0_SERIAL/totalPower",
"payload_format": "number",
"max_age_seconds": 15
}
}
}Do not combine broker_ref with inline connection fields — that is rejected as
ambiguous. TLS brokers are supported ("tls": true, and "tls_insecure": true
only when you explicitly accept unverified certificates; it skips
certificate-chain and hostname verification). A broker profile used
for a D0 grid meter must have source: local_mqtt; Zendure Cloud MQTT D0 grid
meters are not supported. Known D0 samples use positive values for grid import
and negative values for grid export. This integration has unit-test and mocked
MQTT coverage; live D0 hardware validation depends on external tester feedback.
Generic JSON MQTT payload example:
{
"grid_meter": {
"type": "mqtt",
"mqtt": {
"host": "192.168.1.10",
"port": 1883,
"topic": "meter/grid",
"payload_format": "json",
"value_path": "power.total",
"max_age_seconds": 15
}
}
}Shelly uses em:0.total_act_power by default and falls back to summing
em1:0, em1:1, and em1:2 act_power values when the aggregate is not
available.
Use channels when only selected clamps should be summed. A single item list
such as ["c"] is valid and reads only clamp C:
{
"grid_meter": {
"type": "shelly",
"ip": "192.168.1.50",
"channels": ["c"]
}
}Multiple items such as ["a", "c"] sum only those selected clamps:
{
"grid_meter": {
"type": "shelly",
"ip": "192.168.1.50",
"channels": ["a", "c"]
}
}channels entries may be a, b, c, em1:0, em1:1, or em1:2. The
values total and sum are not valid inside channels.
The older non-Pro Shelly 3EM Gen1 meter uses the classic /status endpoint
instead of /rpc/Shelly.GetStatus. Use the shelly_3em_gen1 type for it:
shelly = Shelly Pro / Gen2 / Gen3 via /rpc/Shelly.GetStatus
shelly_3em_gen1 = Shelly 3EM Gen1 via /status
A Shelly 3EM Gen1 reads the top-level total_power by default, falling back to
summing all three emeters[].power values:
{
"grid_meter": {
"type": "shelly_3em_gen1",
"ip": "192.168.1.50"
}
}Use channels only when you intentionally want to read a subset of
phases/clamps. Valid entries are a, b, c, 0, 1, 2, emeter:0,
emeter:1, and emeter:2. Phase letters are normalized to lowercase, and when
channels is configured total_power is ignored:
{
"grid_meter": {
"type": "shelly_3em_gen1",
"ip": "192.168.1.50",
"channels": ["a", "c"]
}
}Clamp direction must match EMS expectations: positive = grid import,
negative = grid export. The sign is not inverted automatically.
On first start, EMS creates the configured runtime-state file automatically.
New generated configs use data/runtime-state.json.
Runtime-state contains operator values like:
- system enabled
- runtime max total power
- runtime loop interval
- runtime minimum output limit
- device enabled
- device runtime max power
- device offgrid socket mode
- device runtime PV priority factor
- winter runtime toggle
Runtime state is temporary runtime data. Do not copy it into config.json or
maintain it as a second static config.
Reset runtime values from config defaults:
rm data/runtime-state.json
python3 -B ems-solarflow-api-control.py --dry-run --onceOlder root-level runtime-state.json files from previous setups are no longer
required after switching to data/runtime-state.json and may be removed
manually.
Use the CLI for safe runtime edits:
python3 emsctl.py status
python3 emsctl.py system min-output-limit 30
python3 emsctl.py device WR1 pv-priority-factor 1.3
python3 emsctl.py device WR1 offgrid eco
python3 emsctl.py winter enablepv-priority-factor changes PV-first weighting only. It does not create
additional PV power and does not override device power limits.
Winter mode is optional and runs as SOC reconciliation, not as normal output control.
{
"winter": {
"enabled": true,
"months": [10, 11, 12, 1, 2, 3],
"summer_min_soc": 15,
"winter_min_soc": 40,
"ramp_step_percent": 5,
"adjust_hour": 12,
"ac_charge_power": 200
}
}Winter SOC adjustments use the same state reconciliation gates that are enabled in the default live profile:
{
"system": {
"dry_run": false,
"allow_hardware_writes": true,
"allow_state_reconciliation_writes": true
}
}inputLimit is only used in the winter/SOC reconciliation context. Do not use
winter mode as a per-cycle mode write mechanism.
Run first:
python3 -B ems-solarflow-api-control.py --dry-run --onceMost installations should keep these defaults. Change them only when the live logs show target oscillation, stale telemetry, or excessive write frequency.
The slower down-ramps (ramp_down_w_per_cycle, device_ramp_down_w_per_cycle)
are intentional: they help prevent undershoot when the inverter output reacts
more slowly than the EMS control target. The values below are the defaults for
newly created configurations; an existing config.json keeps whatever it
already sets (see Compatibility below).
{
"system": {
"output_control": {
"load_deadband_w": 5,
"target_deadband_w": 5,
"filter_enabled": true,
"filter_method": "median_ema",
"median_window": 2,
"ema_alpha": 0.85,
"sign_change_fast_response_enabled": true,
"sign_change_threshold_w": 50,
"sign_change_filter_reset_factor": 1.0,
"ramp_enabled": true,
"ramp_up_w_per_cycle": 500,
"ramp_down_w_per_cycle": 300,
"device_ramp_enabled": true,
"device_ramp_up_w_per_cycle": 400,
"device_ramp_down_w_per_cycle": 200,
"large_import_bypass_w": 600,
"large_export_bypass_w": 600,
"bypass_ramp_multiplier": 1.5,
"telemetry_max_age_seconds": 10,
"stale_telemetry_ramp_factor": 0.5
}
}
}Validate after tuning:
python3 -B ems-solarflow-api-control.py --dry-run --duration 120The default values documented here apply only to newly created
configurations (Fresh Install, and the config template). Loading an existing
config.json never rewrites values you already set: an explicit
loop_interval, deadband, or ramp value is always preserved. Missing keys are
resolved by the normal config-upgrade and default-resolution policy — this is
not a forced migration of running installations.