The EMS uses one static installation config. With the recommended Docker setup, the file is:
config/config.json
On first Docker start, the container creates that file from the versioned template if it does not exist. Existing Docker configs are not overwritten.
New native/developer setups use the same standard layout:
config/config.json
You can also pass an explicit path with --config.
For native/manual setups, create the config from the versioned template:
mkdir -p config data
cp config/config.template.json config/config.jsonOlder native checkouts may still use a root config.json. That legacy layout is
still read as a fallback, but new setups should use config/config.json. See
Config Layout for the full layout and legacy
migration states.
config.json and config/config.json are local and ignored by Git. Do not
commit real Home Assistant tokens, Zendure serial numbers, or local IP
addresses.
- Start Docker once so it creates
config/config.json, or copyconfig/config.template.jsontoconfig/config.jsonfor native Python. - Configure the real grid meter IP.
- Configure one or more real Zendure device IPs and serial numbers.
- Review power, SOC, battery, and PV limits for the installation.
- Keep Home Assistant disabled unless you want HA integration.
- Optionally run
docker compose exec ems python3 emsctl.py config init(Docker) orpython3 emsctl.py config init(native Python) for guided setup. - Optionally set
dry_run=truefor a no-write validation run. - Run diagnostics.
- Monitor the first live run before unattended operation.
The template profile is intended for normal standalone live control after real
local values are configured and installation limits are reviewed:
dry_run=false, allow_hardware_writes=true, and
allow_state_reconciliation_writes=true.
If required placeholders are still present, EMS forces safe mode: control disabled, dry-run enabled, and hardware writes blocked. This prevents an untouched template from writing to hardware.
Docker:
docker compose exec ems python3 emsctl.py diagnoseNative Python:
python3 emsctl.py diagnoseSafe first checks:
python3 -B ems-solarflow-api-control.py --simulate --max-cycles 1
python3 -B ems-solarflow-api-control.py --preflight --dry-run
python3 -B ems-solarflow-api-control.py --dry-run --onceNew releases may add config keys. EMS keeps older configs running by applying
conservative runtime fallback defaults in memory. Normal startup does not
rewrite config.json.
To review missing keys:
python3 emsctl.py config upgrade --dry-runInside Docker:
docker compose exec ems python3 emsctl.py config upgrade --dry-runTo update config.json interactively:
python3 emsctl.py config upgradeInside Docker:
docker compose exec ems python3 emsctl.py config upgradeThe upgrade command uses config.template.json as the source for missing
user-facing config defaults and explanatory _comment* keys. A dry-run also
reports how many existing template-managed comments differ from the current
template.
Before writing normal upgrade changes, EMS asks whether to create a normal
config backup with the existing backup tool. Backups are stored in
data/backups/ by default.
After a successful interactive upgrade, EMS may offer to refresh explanatory
comments from the current template. This optional refresh updates only exact
template-managed _comment* paths. It does not change configuration values or
unknown user keys. If you accept the refresh and no upgrade backup was already
created, EMS creates a normal config backup before writing the comment changes.
For automation:
python3 emsctl.py config upgrade --yes --backup
python3 emsctl.py config upgrade --yes --no-backup--yes remains non-interactive: it applies the normal upgrade according to the
selected backup policy, does not ask about comment refresh, and does not refresh
existing comments.
After upgrading:
Docker:
docker compose exec ems python3 emsctl.py diagnose --deepNative Python:
python3 emsctl.py diagnose --deepconfig.template.json contains the defaults users adopt into config.json.
Runtime fallback defaults are a separate safety net so old or incomplete
configs can still start safely. The config_schema_version value tracks config
compatibility, not the application version.
config.json contains static installation and safety settings:
- Home Assistant URL and token
- grid meter type and IP
- Zendure device IPs and serial numbers
- static device metadata
- safety flags
- output-control defaults
- winter defaults
data/runtime-state.json contains temporary mutable operator/runtime values in
new generated configs:
- EMS enabled state
- runtime max total power
- runtime loop interval
- runtime minimum output limit
- per-device enabled state
- per-device runtime max power
- per-device offgrid socket mode
- per-device runtime PV priority factor
- Home Assistant and winter runtime toggles
The EMS creates the runtime-state file automatically on first start. Deleting it
resets runtime values from config.json defaults. Do not maintain runtime
state as a second static config.
ha.enabled enables Home Assistant publishing and optional helper reads. The
template default is false for standalone operation.
ha.control_enabled allows Home Assistant helpers to update runtime-state
values. The template default is false. It does not grant Zendure
hardware-write permission by itself.
ha.url is the Home Assistant base URL, for example:
http://homeassistant.local:8123
ha.token is a Home Assistant long-lived access token.
Standalone mode:
{
"ha": {
"enabled": false,
"control_enabled": false,
"url": "",
"token": ""
}
}system.enabled is the default EMS enabled state used when runtime-state is
created.
system.dry_run calculates targets but blocks Zendure hardware writes. The
template value is false for normal standalone control after required
placeholders are replaced. Set it to true for a manual no-write validation
run.
system.simulation_mode runs without real hardware. Most users should keep it
false and use --simulate from the command line when needed.
system.allow_hardware_writes allows Zendure /properties/write calls when
dry_run=false. The template value is true so normal outputLimit control
works after local device and grid meter configuration. Required placeholders
still force safe mode and block writes.
system.allow_state_reconciliation_writes allows SOC and mode reconciliation
writes. The template value is true because this is part of the default
regulation profile after local device limits and SOC limits have been reviewed.
Required placeholders still force safe mode and block these writes.
system.reconcile_ac_mode_on_start keeps the legacy startup compatibility gate
enabled. Runtime AC mode intent is evaluated during the control loop, but
startup reconciliation writes happen only when the reported acMode is a known
value and differs from the desired runtime target. This prevents blind repeated
startup writes while still keeping normal output devices aligned to acMode=2;
explicit runtime output intent can still return a device to acMode=2.
Runtime AC charge power is not a static device config value. Set it through
runtime-state, for example with python3 emsctl.py device WR1 ac-charge-power 200. The controller applies it as inputLimit on the next loop only while the
device runtime role is ac_input; in ac_output mode the stored value is
ignored for hardware writes.
system.reconcile_smart_mode allows smart mode reconciliation and is required
for the intended Zendure runtime/RAM mode behavior.
system.log_level controls log verbosity. Common values are info and
debug. The default info level focuses on lifecycle events (startup,
dashboard/Influx start), actual hardware/state writes, and warnings/errors
(unreachable devices, write failures, invalid config, stale telemetry). Normal
per-cycle control-loop traces (output_control_state,
output_control_deadband_hold, output_control_ramp_limited, unchanged
reconciliation events, repeated winter_mode_state) are emitted at debug;
set log_level to debug to see them.
system.max_total_power is the default maximum combined EMS target in watts
(Admin label: Maximum system output). New configurations default to 800 W.
system.max_device_power is the default per-device maximum in watts.
system.deadband is the per-device write-suppression deadband in watts (Admin
label: Device deadband). EMS skips sending a new outputLimit to a device
while the new target is within this many watts of the value the device already
holds; it is distinct from system.output_control.target_deadband_w, which acts
on the total system target. New configurations default to 2 W.
system.runtime_state_path is the path to temporary mutable runtime state. The
default for new generated configs is data/runtime-state.json. Older
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.
system.min_output_limit is the default runtime minimum outputLimit while EMS
is enabled. It also defines the standby total used when positive house load is
present but no active online device has export capacity, and the standby/wakeup
value used by strict night/minSoc idle. Use 0 to disable this floor and the
idle parking behavior.
system.loop_interval is the control loop interval in seconds (Admin label:
Loop interval). New configurations default to 5 seconds. Keep that default
for Zendure Cloud MQTT control: a live SolarFlow 800 Pro 2 measurement observed
a 2.886 s median and 3.012 s p95 from MQTT publish to confirmed device
telemetry. A 3 s loop therefore has effectively no cloud-latency margin, whereas
5 s leaves useful headroom for normal jitter. This interval is the time between
EMS decisions; a load change can wait up to one additional interval before its
command is published, and filtering or ramp limits may intentionally spread the
final target over several cycles. See the
live Cloud MQTT latency measurement
for the measurement definition, complete statistics and limitations.
system.redistribute_clamped_power redistributes target power when one device
is clamped by limits.
system.pv_kwp_weighting weights PV-first distribution by configured PV size.
system.pv_charge_balance_enabled enables a PV-first charge balancing bias.
When total PV can cover the requested output, devices with higher SOC receive
more PV-first output weight so lower-SOC devices can keep more local PV for
charging.
In PV-first mode, devices that are full or charge-inhibited are prioritized for AC output up to their available PV and device limits. This helps Zendure systems use PV from a full battery directly in the house while batteries with charge headroom keep more PV for charging. Export capability, max power, SOC limits, and safety gates still apply.
system.pv_charge_balance_deadband_percent defines the SOC gap where the bias
starts. system.pv_charge_balance_full_bias_percent defines the gap where the
configured bias reaches full strength.
system.pv_charge_balance_strength controls the maximum PV-first charge
balancing bias. Values above 1.0 are clamped to 1.0.
system.battery_kwh_weighting weights battery top-up by configured battery
capacity.
system.soc_reconcile_interval controls how often SOC/mode reconciliation is
checked, measured in EMS cycles. Use 0 to disable cyclic reconciliation.
Manual no-write validation flags:
{
"system": {
"dry_run": true,
"allow_hardware_writes": true,
"allow_state_reconciliation_writes": true
}
}battery_full_charge_assist.enabled defaults to true for new configs; set it
to false in config.json to opt out. When enabled, EMS tracks battery-backed
devices in the core state database and can temporarily request socSet=1000 so
firmware reaches its Max-SoC state within interval_days.
On first enable with an empty EMS state database, EMS assumes the battery was
recently full and schedules the first assist for now + interval_days. It does
not immediately start AC charging only because the feature was enabled. If the
feature is disabled and later enabled again, EMS treats that as a new tracking
start and seeds the schedule from the current time, unless an assist or restore
is already active.
battery_full_charge_assist.assist_window_days allows an early start when the
device is due soon and current SOC is at or above
battery_full_charge_assist.assist_start_soc. On the due day,
battery_full_charge_assist.force_time starts assist at or after the configured
local HH:MM time regardless of current SOC, unless firmware already reports
socLimit == 1.
battery_full_charge_assist.enable_ac_charge_mode controls whether active
assist requests AC input mode through the existing runtime AC mode intent
foundation. There is no separate assist-owned acMode writer.
battery_full_charge_assist.state_database_path defaults to
data/ems_state.sqlite. This is a core EMS database and is independent of the
dashboard database. Deleting it resets full-charge assist history and the
remembered enabled/disabled state.
More detail: battery-full-charge-assist.md.
dashboard.enabled starts the optional web dashboard alongside the EMS loop.
It is read-only by default. Runtime write mode is unavailable until a local
admin password is configured with emsctl dashboard set-password.
dashboard.host and dashboard.port define the listen address. The template
uses 0.0.0.0:8080.
dashboard.database_path stores local SQLite history. Relative paths are
resolved from the project directory.
dashboard.history_hours controls automatic cleanup. Keep it short-term; the
dashboard API supports 1h, 6h, 12h, and 24h ranges.
dashboard.write_interval_seconds limits how often the EMS loop persists
dashboard telemetry. The default is 5.
dashboard.auth_file stores dashboard password hash metadata. Missing file
means dashboard auth is not configured and write mode is unavailable.
dashboard.ssl_enabled switches the same dashboard port from HTTP to HTTPS.
When enabled, dashboard.ssl_cert_file and dashboard.ssl_key_file are used.
If either file is missing and dashboard.ssl_auto_generate=true, EMS creates a
self-signed LAN certificate and restricts the private-key file permissions.
dashboard.session_idle_timeout_seconds (default 1800) is the login idle
timeout that slides on genuine user activity. dashboard.session_absolute_max_seconds
(default 43200) is the hard cap on a session's lifetime measured from login.
For both, 0 disables the bound (an explicit "infinite" opt-in) and negative
values are rejected back to the default. The secure defaults are 30 min / 12 h;
disabling a bound weakens the "walk away → logged out" and stolen-cookie
protections.
dashboard.log_buffer_lines (default 5000) sizes the in-memory log ring buffer
that backs the Logs tab. It is count-based, so quieter systems retain a longer
window; ~5000 lines covers well over 15 minutes at typical settings and costs a
few MB of RAM. dashboard.log_redaction (default false) masks secret-looking
values in served log lines; enable it for shared/remote deployments.
dashboard.animation_mode (default normal) controls how much the animated
energy-flow view animates. It is purely visual and never affects control, auth
or data. Treat it as a preference and accessibility setting rather than a
performance one: measured in Firefox on a GPU, off leaves the frame rate
unchanged and makes the dashboard's main-thread work 1.3 to 2.3 times more
expensive, because the remaining cost is a layout flush that a running animation
keeps small. Whether it still pays on a device weak enough for the saved
compositor work to dominate has not been measured.
normal— full animated flow view (pipe motion, glow/blur filters).reduced— keeps state colours and basic flow indication but drops glow filters and slows pipe motion.off— no continuous pipe animations and no glow/blur filters.
The browser-level prefers-reduced-motion setting is always respected on top of
this mode. The value is exposed read-only at /api/ui-config and applied as a
root CSS class by the frontend.
Dashboard and Admin runtime writes are constrained by the configured system and
device power limits. For example, a runtime max_total_power update cannot
exceed the configured system.max_total_power, and a device runtime max_power
update cannot exceed that device's configured max_power. Admin maintenance
Apply mirrors the overlapping keys it changed into runtime-state through this
same whitelist; a mirrored value the runtime validator rejects is skipped with a
warning while the config write stands. Pure-config keys (ports, credentials,
grid_meter.*, min_soc, …) are not runtime-writable and still take effect only
after an EMS restart.
The dashboard also sends browser security headers, caps JSON request bodies, and limits concurrent Server-Sent Events connections. These protections are for local hardening; public exposure should use a VPN, reverse proxy, strong TLS, and external access control.
energy_savings.enabled controls lightweight daily SQLite energy statistics.
The statistics use measured inverter AC output from the device telemetry, not
control targets or requested output limits.
energy_savings.price_per_kwh and energy_savings.currency are stored on each
daily row when that row is created. Historical savings are summed from the
stored daily values, so old days are not recalculated if the configured price
changes later. A price of 0.0 still tracks kWh and reports zero savings.
energy_savings.max_sample_delta_seconds protects the integration from large
false jumps after restarts or downtime. Intervals above this value are skipped.
energy_savings.timezone defines the calendar timezone used for daily
statistics and period lookups such as Today and Yesterday. It defaults to
Europe/Berlin.
system.output_control is advanced tuning for fast control loops. Most users
should keep the defaults. The ramp and target-deadband knobs below are surfaced
as primary Admin fields (they no longer require opening Advanced settings); the
remaining smoothing and bypass fields stay expert-level. All defaults listed
here apply to newly created configurations only — an existing config.json
keeps whatever it already sets.
load_deadband_w ignores very small load changes before target calculation
(default 5 W).
target_deadband_w holds the total system target when the newly desired total
is only slightly different (Admin label: System deadband). New
configurations default to 5 W.
filter_enabled enables load filtering.
filter_method selects the filter. The default is median_ema.
median_window is the number of load samples used for median filtering.
ema_alpha controls exponential smoothing. Higher values react faster.
sign_change_fast_response_enabled lets the median/EMA filter react faster
when raw_load has already crossed zero with meaningful magnitude but the
smoothed value still points in the old direction.
sign_change_threshold_w is the fixed watt threshold used to qualify a
sign-change mismatch. It is intentionally a fixed configurable value in V1, not
a percentage of system power.
sign_change_filter_reset_factor controls how strongly the smoothed value is
pulled toward raw_load during a sign-change mismatch. 1.0 resets directly to
raw_load. Lower values keep a softer transition.
ramp_enabled limits total target changes per cycle.
ramp_up_w_per_cycle limits how fast the total target can rise (Admin label:
System ramp up). New configurations default to 500 W per cycle.
ramp_down_w_per_cycle limits how fast the total target can fall (Admin label:
System ramp down). New configurations default to 300 W per cycle — the
slower down-ramp helps prevent undershoot when inverter output reacts more
slowly than the EMS control target.
device_ramp_enabled limits per-device target changes.
device_ramp_up_w_per_cycle limits per-device upward changes (Admin label:
Device ramp up). New configurations default to 400 W per cycle.
device_ramp_down_w_per_cycle limits per-device downward changes (Admin label:
Device ramp down). New configurations default to 200 W per cycle — the
reduced down-ramp avoids repeatedly lowering the target while the inverter is
still reacting to an earlier command.
large_import_bypass_w can bypass normal smoothing during large imports.
large_export_bypass_w can bypass normal smoothing during large exports.
bypass_ramp_multiplier increases ramp speed during bypass situations.
telemetry_max_age_seconds marks device telemetry as stale after this age.
stale_telemetry_ramp_factor reduces ramp speed when telemetry is stale.
Winter mode is optional but enabled by default for new configs; set
winter.enabled to false in config.json to opt out.
winter.enabled enables the static winter feature default. The runtime winter
toggle can still enable or disable winter behavior through runtime-state.
winter.months defines active winter months as numbers from 1 to 12.
winter.summer_min_soc is the target minSoc outside winter mode.
winter.winter_min_soc is the desired winter minSoc.
winter.ramp_step_percent limits daily minSoc increases.
winter.adjust_hour is the hour used for daily winter adjustment.
winter.ac_charge_power is the conservative inputLimit used only during the
winter/SOC reconciliation context.
Winter logic runs as SOC reconciliation. It does not change normal output target calculation and must not create per-cycle mode writes.
More detail: winter-mode.md.
Each Zendure device entry defines static installation data:
{
"name": "INV_1",
"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 is the local device name used in logs, Home Assistant entities, and CLI
commands.
ip is the local Zendure device IP address.
sn is the Zendure device serial number.
smart_mode=1 is runtime/RAM mode.
max_power is the default maximum output target for this device.
pv_kwp is the configured PV size used for PV-first weighting.
pv_priority_factor is the default PV-first priority for this device. It can
be overridden at runtime without editing config.json or restarting the EMS:
python3 emsctl.py device WR1 pv-priority-factor 1.3battery_kwh is the configured battery capacity used for battery weighting.
min_soc and max_soc are static SOC boundaries in percent. Use 0 to leave
the corresponding value unmanaged.
enabled is optional and defaults to true. It means the same thing for every
transport: "enabled": false removes the device from the control loop, for a
local-API device exactly as for a Zendure MQTT control device. A non-boolean
value (for example the string "false") is never trusted as enabled and also
removes the device, so a mistyped flag cannot silently keep an inverter under
EMS control. A config whose only devices are disabled has no control device and
does not start.
Static device metadata stays in config.json, not in runtime-state.
pv_priority_factor is an exception: the config value remains the installation
default, while runtime-state can override the active weighting.
grid_meter.type selects the local household/grid power meter implementation.
Positive values mean grid import and negative values mean grid export, unless
the physical device is installed or reports values differently. Run
emsctl config init for guided setup.
| Device / integration | grid_meter.type |
Required fields | Notes |
|---|---|---|---|
| Shelly Pro/Plus Gen2/Gen3 | shelly |
ip |
Uses /rpc/Shelly.GetStatus |
| Shelly 3EM Gen1 | shelly_3em_gen1 |
ip |
Uses /status |
| everHome EcoTracker | ecotracker |
ip |
Uses /v1/json |
| Zendure Grid Meter via local HTTP | zendure_grid_meter_http |
ip (opt. port) |
Internal/discovery generic type. Local REST /properties/report, reads total_power. Works for both D0 and Smart Meter 3CT. Manual setup offers the concrete 3CT/D0 local-API types below. |
| Zendure Smart Meter 3CT — Local API | zendure_smartmeter_3ct_http |
ip (opt. port) |
Local REST /properties/report, reads total_power. Shares the Zendure local-HTTP reader with the D0 local-API meter. |
| Zendure Smart Meter D0 — Local API | zendure_smartmeter_d0_http |
ip (opt. port) |
Local REST /properties/report, reads total_power. Same shared reader as the 3CT; a distinct type so a D0 is never stored as a 3CT. |
| Tasmota HTTP / SmartMeter | tasmota_http |
ip or url, power_path |
Uses Status 10 JSON |
| Zendure Smart Meter D0 — Local MQTT | zendure_smartmeter_d0 |
mqtt.topic + (mqtt.broker_ref or mqtt.host) |
Optional alternative; D0 preset, numeric payload |
| Generic MQTT grid meter | mqtt |
mqtt.host, mqtt.topic, mqtt.payload_format |
Numeric or JSON payload |
The EMS reads http://<ip>/rpc/Shelly.GetStatus. By default it uses the
aggregate em:0.total_act_power value and falls back to summing all em1:*
clamp values if the aggregate is unavailable.
The optional grid_meter.channels field selects individual clamps. Valid
values are a, b, c, em1:0, em1:1, and em1:2. Do not use total or
sum in channels.
Example: Shelly Pro/Plus with all clamps:
{
"grid_meter": {
"type": "shelly",
"ip": "192.0.2.50"
}
}Example: Shelly selected clamp C:
{
"grid_meter": {
"type": "shelly",
"ip": "192.0.2.50",
"channels": ["c"]
}
}Multiple selections such as ["a", "c"] sum only the selected clamps.
The EMS reads http://<ip>/status. By default it prefers the top-level
total_power value and otherwise sums all numeric emeters[].power values.
The optional grid_meter.channels field selects phases or clamps. Valid values
are a, b, c, 0, 1, 2, emeter:0, emeter:1, and emeter:2.
Phase letters are case-insensitive. When channels is configured, the EMS
ignores total_power and sums only the selected emeters[].power values:
a / 0 / emeter:0 -> emeters[0].power
b / 1 / emeter:1 -> emeters[1].power
c / 2 / emeter:2 -> emeters[2].power
The EMS does not invert the sign automatically. Correct reversed clamp polarity on the device.
Example: Shelly 3EM Gen1 with all phases:
{
"grid_meter": {
"type": "shelly_3em_gen1",
"ip": "192.0.2.51"
}
}Example: Shelly 3EM Gen1 selected phases A and C:
{
"grid_meter": {
"type": "shelly_3em_gen1",
"ip": "192.0.2.51",
"channels": ["a", "c"]
}
}The EMS reads http://<ip>/v1/json and uses the flat JSON power field.
Phase values and energy counters are not required for EMS control.
Example: everHome EcoTracker:
{
"grid_meter": {
"type": "ecotracker",
"ip": "192.0.2.60"
}
}The recommended Zendure grid-meter connection. The EMS reads
http://<ip>:<port>/properties/report (port defaults to 80 and the discovered
port is preserved) and uses the flat JSON total_power field. This is a local
REST endpoint that needs no MQTT broker and no app MQTT configuration; current
known firmware exposes it without authentication.
Both a Zendure D0 and a Smart Meter 3CT serve the same flat report, so numeric
total_power alone makes the meter usable and its sign is used as reported. The
per-phase fields (a_aprt_power, b_aprt_power, 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 older zendure_smartmeter_3ct_http type is accepted as a backward-compatible
alias for the same client, so existing configs keep working.
Example: Zendure grid meter via local HTTP:
{
"grid_meter": {
"type": "zendure_grid_meter_http",
"ip": "192.168.1.50"
}
}When you know the model, use the concrete local-API type instead of the generic
one. Both read the same flat total_power from /properties/report through the
shared Zendure local-HTTP reader (there is no second HTTP client); only the
config type and the user-facing label differ, so a manually added D0 is never
stored or shown as a 3CT. Each needs only grid_meter.ip (the discovered port
is preserved). The D0 stays read-only regardless of transport, and the sign is
used as reported (positive import, negative export).
{
"grid_meter": {
"type": "zendure_smartmeter_d0_http",
"ip": "192.168.1.60"
}
}The Local API (zendure_smartmeter_d0_http) and Local MQTT
(zendure_smartmeter_d0) D0 entries are strictly separate: the HTTP entry
carries only ip/port, the MQTT entry carries only the grid_meter.mqtt
block. Switching a meter between them drops the fields that do not belong to the
selected transport.
With grid_meter.ip, the default endpoint is
http://<ip>/cm?cmnd=Status%2010. Alternatively, set grid_meter.url to an
explicit endpoint. grid_meter.power_path is always required and must contain
the dot-separated path to the current power value. The keys depend on the
active Tasmota meter script, so the EMS does not guess them.
Example: Tasmota SML using the default endpoint:
{
"grid_meter": {
"type": "tasmota_http",
"ip": "192.0.2.70",
"power_path": "StatusSNS.SML.Power_curr"
}
}Example: Tasmota with an explicit URL and OBIS-style key:
{
"grid_meter": {
"type": "tasmota_http",
"url": "http://192.0.2.70/cm?cmnd=Status%2010",
"power_path": "StatusSNS.SM.16_7_0"
}
}An optional alternative to local HTTP, for a D0 already publishing to a
broker. The EMS subscribes to an existing MQTT broker; it does not run a broker,
never publishes, and never writes to the D0. The default topic is
Zendure/sensor/<serial>/totalPower, and the payload is numeric watts:
positive = grid import
negative = grid export
Configure the topic (and payload_format, max_age_seconds) under
grid_meter.mqtt. For the connection, prefer a named broker profile with
broker_ref so host, port, TLS and credentials live 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/D0DEMO123456/totalPower",
"payload_format": "number",
"max_age_seconds": 15
}
},
"zendure_mqtt": {
"brokers": {
"local_mqtt": {
"enabled": true,
"source": "local_mqtt",
"host": "192.0.2.10",
"port": 1883,
"tls": false,
"username": "YOUR_MQTT_USER",
"password": "YOUR_MQTT_PASSWORD"
}
}
}
}Rules:
- The D0 topic must be exactly
Zendure/sensor/<serial>/totalPower— four segments, a non-empty serial, and no MQTT wildcards (+/#). Extra path segments, thenumberwrite channel, a foreign/cloud prefix, or a leading/trailing separator are rejected. The canonical topic is generated from the serial, so entering the serial in guided setup is enough. - The referenced broker profile must exist, be enabled, use
source: local_mqtt, and carry a valid host and port. Admin preview validates the broker through the same EMS Core resolver used at startup, so a preview that isreadywill not be rejected at runtime. Zendure Cloud MQTT D0 grid meters are not supported (cloud topic prefixes carry the secret account app key). - The MQTT port must be an integer in the range 1–65535. An explicit invalid
port (
0,-1,70000,"broken", a boolean, …) is rejected, never silently replaced or clamped. When the port is omitted, the protocol default applies (1883plain,8883for TLS). - TLS is supported via the broker profile (
"tls": true; use"tls_insecure": trueonly when you explicitly accept unverified certificates — it skips certificate-chain and hostname verification, which is required for brokers with self-signed certificates such as the Zendure cloud broker). The grid-meter MQTT client applies TLS before connecting and never publishes. - Several local brokers stay separate: each discovered broker keeps its own
profile and
broker_ref, so credentials and TLS settings never cross broker boundaries. - The legacy inline form (host/port/username/password directly under
grid_meter.mqtt, nobroker_ref) still works. Do not combinebroker_refwith inline connection fields — that is rejected as ambiguous.
Live D0 validation currently depends on external tester feedback.
Example: legacy inline broker configuration:
{
"grid_meter": {
"type": "zendure_smartmeter_d0",
"mqtt": {
"host": "192.0.2.10",
"port": 1883,
"username": "YOUR_MQTT_USER",
"password": "YOUR_MQTT_PASSWORD",
"topic": "Zendure/sensor/D0DEMO123456/totalPower",
"payload_format": "number",
"max_age_seconds": 15
}
}
}Use this type for custom MQTT-based meters. It uses the same MQTT client and
backend as the D0 preset. Set grid_meter.mqtt.payload_format to number for
a plain numeric payload, or to json and provide
grid_meter.mqtt.value_path for a JSON payload.
MQTT meters cache the latest parsed value. The control loop does not wait for a
message. If no value has arrived, or its age exceeds
grid_meter.mqtt.max_age_seconds, the meter is treated as stale and the last
cached value is used.
Example: Generic MQTT JSON payload:
{
"grid_meter": {
"type": "mqtt",
"mqtt": {
"host": "192.0.2.10",
"port": 1883,
"topic": "meter/grid",
"payload_format": "json",
"value_path": "power.total",
"max_age_seconds": 15
}
}
}For a numeric payload, use "payload_format": "number" and omit value_path.
Legacy configs with only shelly.ip still work. New configs should use
grid_meter.
If your meter returns a different JSON structure, please open a GitHub issue and include the meter type, relevant config, logs, and an anonymized example payload if possible.
Telemetry from one or more MQTT brokers. The feature is always on and has
no enable toggle: a broker runs as soon as its host is configured (a Zendure
cloud broker additionally needs a stored runtime credential), and without any
broker the runtime is simply inactive — never a config error. A legacy
top-level zendure_mqtt.enabled key in existing configs is ignored;
per-profile enabled flags under brokers still apply. Telemetry is read-only
by default: telemetry-only devices (capabilities.write_output_limit=false)
never publish and never write outputLimit. Publishing happens for control
devices — those with capabilities.write_output_limit=true, a supported write
method and an enabled write gate — see
Zendure MQTT output control
below. A discovered device is controllable only where an exact supported
hardware model resolves to a verified write method, its broker profile
source is a proven carrier for that route, and the write address is complete;
a topic family or hardware generation alone never authorizes writes, and
unknown or conflicting model evidence stays telemetry-only.
Each MQTT device names exactly one broker profile via mqtt.broker_ref. There
is no fallback and no implicit runtime priority: a device assigned to a broker is
only ever satisfied by that broker's telemetry. Define profiles under
zendure_mqtt.brokers so mixed installs are explicit:
{
"zendure_mqtt": {
"stale_after_seconds": 60,
"brokers": {
"zendure_cloud": {
"enabled": true,
"source": "zendure_cloud_mqtt",
"host": "mqtteu.zen-iot.com",
"port": 8883,
"tls": true,
"tls_insecure": true,
"credentials_ref": "zendure-cloud"
},
"local_mqtt": {
"enabled": true,
"source": "local_mqtt",
"host": "192.168.20.10",
"port": 1883
}
}
},
"devices": [
{
"name": "INV_1",
"type": "zendure_mqtt",
"enabled": true,
"serial_number": "…",
"mqtt": {
"broker_ref": "zendure_cloud",
"topic_family": "zensdk_ha_scalar",
"device_id": "…"
},
"capabilities": { "read_power": true, "read_soc": true, "write_output_limit": false }
}
]
}The Admin Fresh Install and Maintenance flows assign new inverters compact
operational names (INV_1, INV_2, …) across Local API and MQTT transports.
That name is the stable key used by runtime state, logs, dashboard devices,
and the EMS Flowchart. Model, address, serial/device ID, transport, maximum
power, and hardware generation remain separate metadata. Existing config names
are not migrated automatically, and the proposed compact name can be edited
before applying.
Notes:
- Broker credentials (
username,password,app_key, tokens) never live inconfig.json. A profile carries a non-secretcredentials_refand the secret is resolved from the external secret store at runtime. Credentials are never returned through status, diagnostics or the Admin UI. This holds for local discovery, manual local-broker setup, the Zendure cloud runtime record and Maintenance alike. - A Zendure cloud profile needs a Core-resolvable runtime credential record
(the encrypted
mqtt-<credentials_ref>.jsonunderconfig/secrets/) holding the complete four-field contract — MQTTusername,password,client_idandapp_key. The runtime builds the cloud connection fromclient_idand its subscriptions fromapp_key, so a record missing or blanking any of the four fields is invalid. Setup and Maintenance apply provision this record automatically when the config references it: the credentials are fetched from the Zendure deviceList (a response lacking any required field is rejected), persisted atomically, verified to resolve back to the complete contract through the Core resolver, and rolled back from a raw pre-change byte snapshot (a rotated — even malformed — record is restored byte for byte, a new one removed) if a later apply step fails. Existing records are validated through the Core resolver at apply time — file existence alone is never trusted. A valid record is reused with no network call by both Setup and Maintenance (they share one staging service); a record that no longer decrypts or is incomplete is reprovisioned when the Zendure API key is saved, and otherwise blocks the apply with the stablecredential_provisioning_failedcode (a partly failed rollback additionally reports a high-severitycredential_rollbacksection naming the affected refs, never secret values). Local broker records follow the same contract against the discovery credential pool, including in-place rotation when the discovered credentials changed; a localcredentials_refstands for real authentication and must resolve to a complete username/password pair — an empty record never downgrades the broker to anonymous access (anonymous brokers simply carry nocredentials_ref). Credential staging and the config write run as one serialized apply transaction shared by Setup and Maintenance. Should the record be missing at runtime anyway, the broker reportsbroker_auth_missingand is never connected (it will not dial the cloud broker anonymously). - An enabled
zendure_mqttdevice must reference a usable broker profile: the profile must exist, be enabled, carry a host/port and a supportedsource, and a cloud profile must have an external credential reference. Otherwise config validation blocks with a sanitized code (zendure_mqtt_broker_ref_unknown/_disabled/_incomplete/zendure_mqtt_broker_auth_missing) that never leaks serials, hosts or secrets. - The broker profile is authoritative for the transport
source(local vs Zendure cloud), which also selects the MQTT write gate. Omitmqtt.sourceon the device; a devicesourcethat contradicts its broker profile is rejected (mqtt_source_mismatch) so device config can never pick a different gate. - Disabled broker profiles may exist as long as no enabled device references them.
capabilities.write_output_limit=trueopts a device in to MQTT output control (see below). Without it, the device stays telemetry-only. An enabled device whose pinned hardware profile does resolve to a supported write method but stays telemetry-only is reported bydiagnoseaszendure_mqtt_control_ready_but_telemetry_only, so an unnoticed downgrade cannot hide as a normal telemetry-only entry.- Backward compatible: an old single-broker block (top-level
host/portwith nobrokers) maps to an implicitdefaultbroker, and devices without amqtt.broker_refuse it. API-only devices need no migration.
A zendure_mqtt device with capabilities.write_output_limit=true is a
control device: it joins the same control loop, target calculation,
distribution and safety gates as API devices. The EMS controller stays the
source of truth for demand, distribution and write decisions; MQTT is a
first-class control transport that builds the write topic and payload.
Output control is enabled per device by capability: it is available where the
pinned hardware profile resolves to a verified write method on the device's
broker source, decided by the shared helper
ems.zendure_mqtt.capability.mqtt_output_control_capability. Admin Setup,
Maintenance and manual entry all create a controllable device for a supported
model without hand-editing config.json.
write_output_limit is a capability, not an operator preference. Admin
derives it from four independent axes — the pinned model, an implemented write
route for that model, a broker source that carries that route, and a complete
write address (mqtt.product_key plus mqtt.device_id) — and shows it
read-only, so a control-capable inverter is
controlled whenever it is enabled, the same rule a Local API device follows
(which carries no such key at all). Whether a device participates at all is its
enabled flag, and that is the only activation authority: a device that cannot
control output is telemetry-only by capability and stays active. Hand-editing
write_output_limit=false on a control-capable device remains valid config and
keeps the device telemetry-only, but Admin will re-derive it to true when the
Maintenance draft is loaded, where it appears in the preview diff.
Local API, Local MQTT and Zendure Cloud MQTT are alternative control
transports of the same logical device. Switching between them preserves the
device's identity, enabled state, limits, SoC settings and allocation
parameters; enabled is never a capability decision. Whether the new connection
can control the device is, however, evaluated per transport: the broker source
is one of the capability axes, so a device moved to a broker whose write route
is unverified stays enabled and becomes a telemetry source, with the reason
shown on its card. See
Model-Aware Zendure MQTT Power Control
for the source matrix and the machine-readable reasons
(broker_source_write_unverified, broker_source_unknown).
Scope of MQTT control. Output-limit control is supported where the resolved
hardware profile carries an implemented write method: zensdk_properties_write
(ZenSDK properties/write) or the legacy_hub_device_automation /
legacy_object_device_automation function/invoke automation commands.
Full API state reconciliation — Smart Mode, AC Mode, SoC setting, winter/full-
charge assist — is API-only and is not available over MQTT
(supports_state_reconciliation=False for MQTT control devices). Some older
Zendure generations and topic families have not been validated on the
maintainer's own hardware; please report anonymized MQTT traces and results.
Each control device must resolve to an explicit, supported write method
before it can publish. The pinned hardware_profile selects it
(registry: ems/mqtt_control/zendure_profiles.py); a topic family never does:
| Hardware profile | Write method | Notes |
|---|---|---|
| SolarFlow 800 / 800 Plus / 800 Pro / 800 Pro 2 / 1600 AC+ / 2400 AC / 2400 AC+ / 2400 Pro / 4000 AC+ | zensdk_properties_write |
Publishes {deviceId, messageId, timestamp, properties:{outputLimit}} to iot/<productKey>/<deviceId>/properties/write. Needs mqtt.product_key. |
| Hyper 2000 / AIO 2400 | legacy_object_device_automation |
Publishes a deviceAutomation function/invoke command to iot/<productKey>/<deviceId>/function/invoke; acknowledged on function/invoke/reply. Needs mqtt.product_key. |
| Hub 1200 / Hub 2000 | legacy_hub_device_automation |
Same function/invoke topic with a scalar watt value; acknowledged on function/invoke/reply. Needs mqtt.product_key. |
| ACE 1500 / SuperBase V4600 / SuperBase V6400 | telemetry_only — read-only |
Never publishes. |
| none pinned | none — read-only | A topic family or hardware generation alone never selects a write method; only the explicit escape hatch below can. |
| none pinned, with explicit topic | custom_properties_write |
Explicit advanced escape hatch: mqtt.write_protocol set to custom_properties_write plus an explicit valid mqtt.write_topic; publishes the same properties payload to that topic. |
Telemetry family and write family are separate. mqtt.topic_family names
how a device's reports are parsed (zensdk_ha_scalar,
zendure_cloud_scalar, legacy_zendure_json, legacy_zendure_json_alt). Every
built-in write method publishes to iot/<productKey>/<deviceId>/… regardless of
it, so the observed telemetry family never decides whether a device is
controllable. What a scalar family does not carry is a product key — its
topics have no such segment — so a device discovered only through scalar
telemetry has an incomplete write route until the product key is known from the
cloud device list, an existing config or manual entry. That is reported as
write_target_missing, not as a transport problem. See
docs/technical/zendure-mqtt-power-control.md for the full capability model.
Pin hardware_profile to a supported model to make a device controllable;
mqtt.write_protocol accepts only the explicit custom_properties_write escape
hatch, never a built-in write method name. An enabled control device that does
not resolve to a supported write method (or is otherwise unaddressable) fails
config validation, and startup aborts rather than silently controlling fewer
inverters.
MQTT control writes are gated separately from API writes by transport:
system flag |
Default | Gates |
|---|---|---|
allow_mqtt_local_control_writes |
true |
devices on a local_mqtt broker |
allow_mqtt_zendure_control_writes |
true |
devices on a zendure_cloud_mqtt broker |
All gates default on in the template; switch a gate off for read-only
validation of its transport. Configs missing the system keys resolve to the
same release defaults (all gates on) at load time, while the simulation/replay
safe config and template placeholder safety force every gate off until real
values are configured.
Both still require the shared precondition (dry_run=false,
simulation_mode=false, not replay). Control devices are output-only: they are
excluded from the read-only telemetry runtime and from every state
reconciliation writer. Their telemetry is subject to freshness: a stale or
missing snapshot (broker disconnect / stalled updates) is treated as an
unavailable read, so the controller never acts on disconnected devices.
Mock and in-process broker tests verify EMS integration and broker isolation. They do not prove that every Zendure firmware accepts the generated write command. Please open a GitHub issue for both successful and unsuccessful device tests, including model, firmware, topic family and anonymized diagnostics.
Compile:
python3 -m py_compile ems-solarflow-api-control.pySimulation:
python3 -B ems-solarflow-api-control.py --simulate --max-cycles 1Live read-only preflight:
python3 -B ems-solarflow-api-control.py --preflight --dry-runThe copied template is already configured for normal live standalone control after you enter real local values and review installation-specific limits:
{
"system": {
"dry_run": false,
"allow_hardware_writes": true,
"allow_state_reconciliation_writes": true,
"reconcile_ac_mode_on_start": true,
"reconcile_smart_mode": true
}
}Use this optional staged validation path when you want extra caution:
- Set
dry_run=true. - Validate telemetry and grid meter readings with simulation, preflight, and dry-run.
- Set
dry_run=false. - Use bounded live runs for first tests and monitor the result.
Example bounded live run:
python3 -B ems-solarflow-api-control.py --duration 120State reconciliation writes are enabled in the default regulation profile:
{
"system": {
"dry_run": false,
"allow_hardware_writes": true,
"allow_state_reconciliation_writes": true
}
}More examples: configuration-examples.md.