emsctl.py safely edits the configured runtime-state file.
Runtime-state edits and an ordinary diagnose contact no hardware and no Home
Assistant. Two commands do reach the network, read-only and with short
timeouts: diagnose --hardware probes configured grid meters and Zendure read
endpoints, and grid-meter test reads the configured meter. Neither writes
device state.
Running the CLI without arguments prints a short start screen:
python3 emsctl.pyStart with the interactive menu, built-in help, or the command cookbook:
python3 emsctl.py interactive
python3 emsctl.py --help
python3 emsctl.py examplesCommon runtime edits:
python3 emsctl.py status
python3 emsctl.py system disable
python3 emsctl.py system max-power 1200
python3 emsctl.py device WR1 max-power 600
python3 emsctl.py device WR1 ac-mode output
python3 emsctl.py device WR1 ac-mode input
python3 emsctl.py device WR1 ac-charge-power 200
python3 emsctl.py device WR1 offgrid eco
python3 emsctl.py winter enable
python3 emsctl.py dashboard auth-status
python3 emsctl.py diagnosediagnose is designed for local support/debug output. Normal diagnose is
read-only and does not contact Zendure hardware, Home Assistant, MQTT, Shelly,
or other external services.
python3 emsctl.py diagnose
python3 emsctl.py diagnose --deep
python3 emsctl.py diagnose --hardware
python3 emsctl.py diagnose --control
python3 emsctl.py diagnose --control --sample-seconds 30
python3 emsctl.py diagnose --control-quality --sample-seconds 60
python3 emsctl.py diagnose --quality --json
python3 emsctl.py diagnose --json
python3 emsctl.py diagnose --support-bundle
python3 emsctl.py diagnose --support-bundle --output /tmp/ems-support.zip| Command | Purpose |
|---|---|
diagnose |
Installation health |
diagnose --deep |
Advanced health checks |
diagnose --hardware |
Hardware connectivity |
diagnose --control |
Explain EMS decisions |
diagnose --control-quality |
Evaluate EMS quality |
diagnose --support-bundle |
Generate support bundle |
Use diagnose first when something is unclear. Use --control when EMS is
running but the current output looks surprising. Use --control-quality when
the system regulates but export/import, PV usage, or SOC balancing looks poor
over time. Use --support-bundle before opening a GitHub issue or forum
support request.
Normal diagnose also includes the optional battery full-charge assist section.
It reads the core EMS state database when present and reports per-device
last_full_charge_at, next_due_at, pending restore flags, and read-only
firmware diagnostics. Diagnose does not create the assist database.
Modes:
--jsonprints the same result structure as machine-readable JSON. The diagnose API contract is versioned with top-levelschema_version: 1.--deepadds local operational checks: runtime-state plausibility, SQLite integrity/table summaries, recent configured log patterns, Docker host hints, and a dashboard loopback check when enabled.--hardwareperforms explicit short-timeout read-only network probes for configured grid meters and Zendure read endpoints. It never writes hardware state. The output also includes compact grid-meter and per-device communication health (see "Communication health" below).--controlexplains the current regulation path from local config and runtime-state: grid power, filtered grid power, target output, final output, deadband state, device allocation, SOC protection, write-path blockers, and likely root causes.--sample-seconds Ncan be combined with--controlto collect local runtime-state meter samples. The output reports average/min/max, standard deviation, sign changes, and stale/noisy meter hints.--control-qualityor--qualityevaluates real operation over local samples: export/import quality, a coarse regulation quality score, PV usage plausibility, SOC balancing, and higher-level root-cause hints.--support-bundlecreates a redacted ZIP with a stable file layout:diagnosis.json,diagnosis.txt,control-diagnostics.json,control-diagnostics.txt,control-quality.json,control-quality.txt,redacted-config.json,runtime-state.json, andbundle-metadata.json.
Control interpretation:
Control disabledmeans runtime control is explicitly off.Dry run enabledmeans EMS calculates targets but skips hardware writes.Deadband activemeans the filtered meter value is inside the configured threshold and output may be held.Grid meter signal appears noisymeans frequent sign changes or high variance were observed in local samples.Minimum SOC protection activemeans at least one device is at or below its minimum SOC.- Runtime AC input mode is shown as blocked device writes in control
explanation data. AC input devices are excluded from normal output allocation
until their runtime intent returns to output mode and
acMode=2has been reconciled.
diagnose --hardware reports lightweight communication health so intermittent
read/write problems are visible without digging through logs:
Grid meter health:
provider: Shelly
status: ok
last success: 3s ago
consecutive errors: 0
last latency: 42 ms
stale value used: no
Device health:
WR1:
read: ok, last success 2s ago, consecutive errors 0, last latency 180 ms
write: ok, last success 35s ago, consecutive errors 0
- Grid-meter health tracks reads of the configured grid meter (Shelly,
EcoTracker, Tasmota, MQTT, ...).
stale value used: yesmeans the read failed, no fresh MQTT value arrived, or a cached MQTT value exceededgrid_meter.max_age_seconds; EMS kept the last known value rather than reacting to a bad reading. This is intentional fallback behavior, not a control bug. - Device read/write health is tracked separately per device. Reads and writes never share state: a device can be readable while writes fail or are intentionally blocked (for example a device parked in AC-input mode).
- Status is
ok(recent success),degraded(recent failures or a stale value, but still a recent good value),failed(no recent good value or repeated consecutive failures), orunknown(nothing attempted yet).
Health counters are in-memory only and reset when EMS restarts; they describe current runtime health, not long-term history.
Runtime communication health is kept in memory by the running EMS process. It resets when EMS restarts and is intended for live diagnostics, dashboard/API exposure, or future telemetry export.
emsctl diagnose --hardware is a fresh read-only probe from the CLI process. It
checks the currently configured meter and devices at the time the command is
run. It does not read historic runtime counters from the running EMS process.
Repeated grid-meter read timeouts (for example Shelly ReadTimeoutError on
/rpc/Shelly.GetStatus) usually indicate a slow or unresponsive meter or a
flaky network, not an EMS control problem. Power-cycling the meter often clears
it. Use grid-meter test to confirm:
python3 emsctl.py grid-meter test
python3 emsctl.py grid-meter test --duration 120 --interval 1Grid meter read test: Shelly 192.168.100.93
Duration: 120s
Reads: 120
OK: 117
Failed: 3
p50 latency: 42 ms
p95 latency: 310 ms
max latency: 3012 ms
For MQTT grid meters the command subscribes through the configured broker and
reports Latest power once a fresh value has arrived. If the broker is
reachable but the topic is wrong or stale, the output says no fresh MQTT value
was received.
grid-meter test is read-only and does not write to any device. It exits
non-zero if any read fails.
python3 emsctl.py device WR1 ac-mode output
python3 emsctl.py device WR1 ac-mode input
python3 emsctl.py device WR1 ac-charge-power 200ac-mode output writes runtime_role=ac_output to runtime-state. The
controller reconciles the inverter to acMode=2 during the normal control loop
and allows normal EMS output allocation. ac-mode input writes
runtime_role=ac_input, targets acMode=1, and excludes the device from
normal EMS output allocation. emsctl changes runtime-state only and does not
write raw acMode numbers or contact inverter hardware.
AC charge power is runtime-only:
python3 emsctl.py device WR1 ac-charge-power 200
python3 emsctl.py device WR1 ac-mode input
# Later return to normal EMS output regulation
python3 emsctl.py device WR1 ac-mode outputac-charge-power writes ac_charge_power_w to runtime-state and preserves
the current runtime role. The controller applies it as Zendure inputLimit on
the next EMS loop only while the device role is ac_input, and only when
telemetry reports a different current inputLimit. While the role is
ac_output, the stored charge power is ignored for hardware writes so it can
be prepared before switching to input mode.
Control quality interpretation:
- The quality score is a coarse support indicator from 0 to 100, not a certified measurement. It starts at 100 and subtracts bounded penalties for average grid deviation, export duration, export peaks, and large import peaks.
excellent,good,acceptable,poor, andcriticalare intended for triage. Use the detailed export/import metrics to understand the cause.- Export peaks mean grid power went negative during the sample window. Short small peaks can be normal; long or large peaks indicate the zero-export target is not being held consistently.
- PV diagnostics can show that PV telemetry is missing, PV is likely limited by system/device limits, or PV is available but not used. It cannot prove a hardware fault without additional read-only hardware checks.
- SOC balancing warnings mean the SOC spread is high, a low-SOC device is contributing more than expected, or a device is protected by minimum SOC.
Exit codes:
0 diagnose status is ok or warning
1 at least one diagnostic error was found
2 invalid CLI usage
Before opening an issue:
python3 emsctl.py diagnose --support-bundleAttach the generated ZIP. The bundle is redacted and includes the diagnostic summary, redacted config/runtime-state snapshots, bundle metadata, and control/quality diagnostics. Control files are present even when the corresponding mode was not enabled so automated tooling can rely on the same file names.
Machine-readable root causes always use this shape:
{
"code": "control_disabled",
"severity": "warning",
"title": "Control disabled",
"message": "Control disabled",
"suggested_next_check": "Review the related diagnose section for details."
}By default, emsctl.py uses this config lookup order:
--config PATH
EMS_CONFIG_FILE
config.json
config/config.json
This preserves legacy local setups that keep config.json next to
emsctl.py, while allowing the recommended Docker setup to use
/app/config/config.json automatically.
Relative runtime_state_path and dashboard auth_file values are still
resolved relative to the application directory. The runtime-state path always
comes from the selected config unless --runtime-state is passed explicitly.
config init is the optional first setup assistant. It helps fill common
settings and does not blindly replace edited configs.
Native Python:
python3 emsctl.py config init
python3 emsctl.py config init --dry-run
python3 emsctl.py config init --yes --backup
python3 emsctl.py config init --yes --no-backupDocker:
docker compose exec ems python3 emsctl.py config initEdited configs need an explicit backup decision for non-interactive writes:
use --yes --backup to create a backup first, or --yes --no-backup when you
intentionally do not want one.
config upgrade is different from config init. It fills missing persisted
keys after updates by comparing your config with config.template.json.
Dry-run output includes missing keys, missing explanatory comments, and the
number of existing template-managed _comment* entries whose text differs from
the current template.
Native Python:
python3 emsctl.py config upgrade --dry-run
python3 emsctl.py config upgradeDocker:
docker compose exec ems python3 emsctl.py config upgrade --dry-run
docker compose exec ems python3 emsctl.py config upgradeWhen run interactively, config upgrade can optionally refresh outdated
template-managed explanatory comments after the normal upgrade completes. The
refresh changes only exact _comment* paths from the template; configuration
values and unknown user keys are preserved. Non-interactive --yes runs do not
prompt for or apply this comment refresh.
With the recommended Compose service name ems, common commands can be run
without an explicit config path:
docker compose exec ems python3 emsctl.py status
docker compose exec ems python3 emsctl.py interactive
docker compose exec ems python3 emsctl.py dashboard auth-status
docker compose exec ems python3 emsctl.py config init
docker compose exec ems python3 emsctl.py config upgrade --dry-run
docker compose exec ems python3 emsctl.py diagnose
docker compose exec ems python3 emsctl.py diagnose --control
docker compose exec ems python3 emsctl.py diagnose --control-quality --sample-seconds 60
docker compose exec ems python3 emsctl.py diagnose --deep
docker compose exec ems python3 emsctl.py diagnose --support-bundleFor unusual mounts or troubleshooting, an explicit config path still works:
docker compose exec ems python3 emsctl.py --config /app/config/config.json statusEach command group also has focused help:
python3 emsctl.py system --help
python3 emsctl.py device --help
python3 emsctl.py dashboard --helpinteractive opens a dependency-free menu for common runtime edits:
python3 emsctl.py interactiveAlias:
python3 emsctl.py menuThe menu works directly with Python standard input/output and does not require Bash or Zsh completion setup. It can show status, edit system limits, toggle HA publishing/helper control, toggle winter mode, edit configured devices, show or manage dashboard authentication, and open the Backup / Restore submenu (see Backup / Restore).
Interactive mode preserves the same safety rules as direct commands:
- no Zendure hardware access
- no Home Assistant access
- no dashboard server access
- no plaintext password echo
- same value validation
- same atomic runtime-state writes
examples prints a longer read-only cookbook grouped by topic:
python3 emsctl.py examplesThis command does not create or modify the runtime-state file.
Completion is optional and generated without third-party packages. It is not required for interactive mode.
Bash, current shell:
source <(python3 emsctl.py completion bash)Bash, persistent user install:
mkdir -p ~/.local/share/bash-completion/completions
python3 emsctl.py completion bash > ~/.local/share/bash-completion/completions/emsctlZsh, current shell:
source <(python3 emsctl.py completion zsh)The generated completion covers top-level commands, command actions, dashboard
subcommands, offgrid modes (off, eco, standard), and configured device
names when config.json is readable. Completion generation does not create or
modify runtime-state.json.
Use explicit config paths when generating completion for a non-default installation:
python3 emsctl.py --config /etc/ems/config.json completion bash
python3 emsctl.py --config /etc/ems/config.json completion zshpython3 emsctl.py statusWith explicit paths:
python3 emsctl.py --config config.json status
python3 emsctl.py --runtime-state runtime-state.json status
python3 emsctl.py --runtime-state data/runtime-state.json statusstatus creates the configured runtime-state file from config defaults when
the file is missing.
python3 emsctl.py system enable
python3 emsctl.py system disable
python3 emsctl.py system max-power 1600
python3 emsctl.py system loop-interval 5
python3 emsctl.py system min-output-limit 30python3 emsctl.py ha enable
python3 emsctl.py ha disable
python3 emsctl.py ha-control enable
python3 emsctl.py ha-control disableha controls runtime HA publishing. ha-control controls runtime HA helper
sync. Neither command edits HA URL or token.
python3 emsctl.py winter status
python3 emsctl.py winter enable
python3 emsctl.py winter disableThis only toggles winter mode at runtime. Winter SOC targets, months, ramp
timing, and AC charge power remain static config.json settings.
python3 emsctl.py device WR1 enable
python3 emsctl.py device WR1 disable
python3 emsctl.py device WR1 max-power 800
python3 emsctl.py device WR1 pv-priority-factor 1.3
python3 emsctl.py device WR2 pv-priority-factor 0.7
python3 emsctl.py device WR1 offgrid off
python3 emsctl.py device WR1 offgrid eco
python3 emsctl.py device WR1 offgrid standardpv-priority-factor adjusts the device's PV-first allocation weight at
runtime. Values above 1.0 increase the device's PV-first share, values below
1.0 reduce it. The value is still limited by real PV availability, device
state, SOC logic, and configured power limits.
Dashboard write mode is disabled until a local admin password is configured:
python3 emsctl.py dashboard set-password
python3 emsctl.py dashboard change-password
python3 emsctl.py dashboard disable-auth
python3 emsctl.py dashboard auth-statusPasswords are prompted without echo. The password file contains only
PBKDF2-SHA256 hash metadata and no plaintext password. disable-auth removes
the password file and makes dashboard write mode unavailable again.
Hidden password automation flags exist for tests and non-interactive automation but are intentionally omitted from normal help. Do not use them for interactive terminal sessions because shell history and process listings can expose command arguments.
Manual config and database backup/restore for moving a setup to another device or recovering a broken installation without copying files by hand.
For a beginner-friendly, step-by-step workflow (backup before an update, dry-run restore checks, full local restore order) see the Backup and Restore Guide. This page is the detailed command reference.
python3 emsctl.py backup # interactive menu
python3 emsctl.py backup create # config backup
python3 emsctl.py backup create --type databases
python3 emsctl.py backup create --type influxdb
python3 emsctl.py backup create --compression-level 3
python3 emsctl.py backup inspect data/backups/ems-config-manual-2026-06-18-221500.tar.gz
python3 emsctl.py backup restore data/backups/ems-config-manual-2026-06-18-221500.tar.gz
python3 emsctl.py backup restore data/backups/ems-databases-manual-2026-06-18-221500.tar.gz
python3 emsctl.py backup restore data/backups/ems-influxdb-manual-2026-06-18-221500.tar.gz
python3 emsctl.py backup diff data/backups/ems-config-manual-2026-06-18-221500.tar.gz --file config.jsonBackups are stored in data/backups/ by default. Docker users see the same
folder on the host because data/ is mounted into the container. Older
versions may have used backup/; existing archives there can still be restored
by passing the archive path.
python3 emsctl.py backup (no action) opens a small menu:
Backup / Restore
[1] Create config backup
[2] Create database backup
[3] Create InfluxDB backup
[4] Restore backup
[5] Inspect backup
[6] Exit
A config backup is a sortable tar.gz archive written to data/backups/:
data/backups/ems-config-manual-2026-06-18-221500.tar.gz
data/backups/ems-config-manual-2026-06-18-221500.tar.gz.enc # password-protected
data/backups/ems-config-rollback-2026-06-18-222000.tar.gz # auto rollback
Included files (when present and configured):
config.json- the runtime state (
system.runtime_state_path) - dashboard auth/cert/key (
dashboard.auth_file,ssl_cert_file,ssl_key_file) - the bundled InfluxDB secret (
influxdb.secret_file) — only wheninfluxdb.enabledis true andinfluxdb.modeisbundled
Every archive contains a backup-manifest.json with the backup format, type,
purpose, UTC timestamp, build metadata (git commit/branch/describe), contract
versions, and per-file sensitivity flags and SHA256 checksums.
Config backups may contain secrets.
config.json, the dashboard auth file, the dashboard TLS key and the InfluxDB secret are flagged sensitive. The CLI prints a sensitive-data warning before creating a backup.
backup create --type databases backs up the local SQLite databases into a
tar.gz archive written to data/backups/:
data/backups/ems-databases-manual-2026-06-18-221500.tar.gz
data/backups/ems-databases-manual-2026-06-18-221500.tar.gz.enc # password-protected
data/backups/ems-databases-rollback-2026-06-18-222000.tar.gz # auto rollback
Included databases (only when present):
data/ems_dashboard.sqlite(dashboard.database_path) — dashboard/history DBdata/ems_state.sqlite(battery_full_charge_assist.state_database_path) — local EMS state (calibration / full-charge assist)
Each database is snapshotted with the SQLite online backup API into a temporary
staging directory before archiving, so the stored copy is internally consistent
even while the EMS is running — live files are never copied directly. Missing
databases do not fail the backup; they are recorded in the manifest as
included: false.
Database and InfluxDB backups may contain historical energy and runtime data —
not classic secrets such as passwords or tokens, but local usage history that
can reveal usage patterns. SQLite database files are flagged privacy_relevant
in the manifest (not sensitive). Use an encrypted backup if you want to
protect local usage history. The manifest records a databases list and an
influxdb block describing detected analytics.
InfluxDB data is not part of a database backup. A database backup covers the local SQLite files only. When InfluxDB analytics is enabled (bundled or external) the CLI notes that InfluxDB data is detected but not in this archive, and the manifest records it as
{"included": false, "reason": "use_influxdb_backup_type"}. Usebackup create --type influxdb(below) to back up bundled InfluxDB data.
backup create --type influxdb backs up the bundled InfluxDB analytics data
using the official influx backup CLI, then packages the output into a tar.gz
archive written to data/backups/. Docker users run the command inside the
ems container (the CLI ships in the image and talks to the bundled InfluxDB
over the Docker network — no Docker socket required); native users run it from
the repo, where it drives the bundled ems-influxdb container via
docker compose:
data/backups/ems-influxdb-manual-2026-06-18-221500.tar.gz
data/backups/ems-influxdb-manual-2026-06-18-221500.tar.gz.enc # password-protected
data/backups/ems-influxdb-rollback-2026-06-18-222000.tar.gz # auto rollback
The archive contains backup-manifest.json plus an influxdb/ directory with
the official backup output (KV/SQL store and bucket data). The live
data/influxdb bind mount is never copied directly. The manifest records an
influxdb block:
"influxdb": {
"included": true,
"mode": "bundled",
"service": "influxdb",
"container_name": "ems-influxdb",
"org": "ems",
"bucket_prefix": "ems",
"backup_method": "influx backup",
"data_dir": "data/influxdb"
}What is included: all InfluxDB buckets, tasks and history captured by the official backup, packaged with checksums. What is not included: config, SQLite databases, and any external InfluxDB (use your provider's backup tool).
Supported modes:
- Bundled (
influxdb.enabled: true,influxdb.mode: bundled) — supported. Requires Docker/Compose and a usable token (influxdb.token, theinfluxdb.token_envvariable, or the generateddeploy/docker/influxdb.env). The container is started if it is not already running. - External (
influxdb.mode: external) — rejected with a clear message; use your external InfluxDB backup strategy. - Disabled — nothing to back up; the command is a no-op.
InfluxDB backups may contain historical energy and runtime data and InfluxDB metadata. The CLI offers optional password protection (same
.tar.gz.encformat as config/database backups). Tokens are never embedded in the archive or the manifest, and the admin token is passed to the container via its environment, never on the command line.
Backup archives are timestamped to the second. If two backups of the same type
and purpose land in the same second, the second one is written to a unique
...-2, ...-3, … name instead of overwriting the first. The archive is built
into a temporary file and atomically linked into place, so an existing backup is
never silently clobbered and no partial archive is left behind on failure.
ems-config-manual-2026-06-20-120000.tar.gz
ems-config-manual-2026-06-20-120000-2.tar.gz
ems-config-manual-2026-06-20-120000.tar.gz.enc
ems-config-manual-2026-06-20-120000-2.tar.gz.enc
A backup source file that is a symlink is rejected with a clear error
(Refusing to back up symlink path: <path>) and no partial archive is written.
On restore, only regular-file manifest entries are accepted; a crafted archive
whose member is a symlink is rejected.
Manual backups can be encrypted into a .tar.gz.enc file:
python3 emsctl.py backup create --password
python3 emsctl.py backup create --type config --password --encryption aes-256-gcm
python3 emsctl.py backup create --type influxdb --password --chunk-size 4M --kdf-iterations 300000New encrypted backups use a versioned streaming format (format version 2): the archive is encrypted in independently authenticated chunks, so neither encryption nor decryption ever loads the whole archive into memory — suitable for larger InfluxDB backups on a Raspberry Pi 4.
- Default algorithm: ChaCha20-Poly1305 (fast on ARM hardware without AES
acceleration). Optional: AES-256-GCM (
--encryption aes-256-gcm). - KDF: PBKDF2-HMAC-SHA256, default 300000 iterations (
--kdf-iterations). - Default chunk size: 4 MiB (
--chunk-size, accepts e.g.4M/512K/bytes). - All parameters (algorithm, KDF, iterations, chunk size, salt, base nonce) are stored in a self-describing header, so restore auto-detects the algorithm.
Each chunk binds the format version, algorithm, chunk index and final-chunk
marker as authenticated data, so a wrong password, modified ciphertext, a
truncated file, reordered chunks, and unsupported algorithms/versions all fail
cleanly. The encrypted header and per-chunk metadata are bounds-checked before
any decryption, so a malformed or hostile .enc file is rejected with a
backup-format error before any restored file is written. Invalid --encryption,
--chunk-size, --kdf-iterations or --compression-level values are rejected
with a clear message (no traceback) and no partial archive.
Legacy compatibility: existing whole-file Fernet encrypted backups (format version 1) remain restorable — restore detects the format from the header and decrypts them through the legacy path. Only new backups use the streaming format.
The password is entered twice, never stored, and never logged. Restoring or inspecting an encrypted backup prompts for the password; a wrong password aborts cleanly. In non-interactive mode an encrypted restore fails with a clear message.
python3 emsctl.py backup restore data/backups/ems-config-manual-2026-06-18-221500.tar.gz
python3 emsctl.py backup restore data/backups/...tar.gz --on-conflict keep --no-rollback
python3 emsctl.py backup restore data/backups/...tar.gz --dry-runRestore detects the backup type from its manifest. Config and database backups
follow the file-restore flow below; InfluxDB backups follow the dedicated
replace flow described in Restoring an InfluxDB backup.
Interactive restore first asks whether to create a
rollback backup (backup_purpose=rollback, matching the backup type). When you
choose to create one, the CLI then asks whether to password-protect the
rollback backup:
Create rollback backup before restore? [y/n/a]
Rollback backup may contain sensitive data.
Protect rollback backup with password? [y/n/a]
Enter rollback backup password:
Repeat rollback backup password:
The rollback password is independent of the source backup password and is
never reused automatically — you may restore an encrypted backup while keeping
an unencrypted rollback, or the reverse. A password-protected rollback is
written as ...-rollback-....tar.gz.enc; an unprotected one stays a plain
.tar.gz. If the two rollback passwords do not match the restore aborts and no
partial rollback archive is created; if rollback creation fails the restore
does not start. In non-interactive mode the rollback (when requested) is created
unencrypted — the CLI never silently produces an encrypted rollback.
After the rollback step, restore continues. For each existing file that differs
you can keep the current file, replace it with the backup version, show a
unified diff (binary databases report that no diff is shown), or abort.
Identical files are skipped silently. Because a database backup stores a
re-serialized SQLite snapshot, restoring over an unchanged database is normally
reported as a conflict; existing database files are never overwritten without
explicit confirmation or an explicit --on-conflict replace. After a database
restore the CLI notes that InfluxDB data was not part of the backup.
Non-interactive options:
--on-conflict abort|keep|replace(default for scripts:abort)--rollback/--no-rollback--dry-run— show the plan without writing
--dry-run is conflict-safe: it reports a plan for every file and never
writes files, creates rollback archives, requires conflict decisions, or aborts
just because a target file differs. Each file is reported with an explicit
action — would_restore_new, would_replace_conflict, would_skip_identical
(and would_restore_influxdb for an InfluxDB backup). Validation still runs in
dry-run: archive structure and manifest checksums are checked, and path
traversal, unsafe entries, corrupted backups, wrong passwords and unsupported
formats still fail.
Restore is safe by construction: only files listed in backup-manifest.json
are restored, archive entries with absolute paths or .. traversal are
rejected, and every file's SHA256 is validated before it is written.
After a successful config/database restore the CLI recommends:
python3 emsctl.py diagnose --deep
Restoring an ems-influxdb-* archive uses the official influx restore --full
mechanism inside the bundled container, which replaces all bundled InfluxDB
data (KV/SQL store and every bucket). It is intentionally conservative:
InfluxDB restore can replace existing bundled analytics data.
Create rollback InfluxDB backup before restore? [y/n/a]
Protect rollback backup with password? [y/n/a]
Restore strategy: [r] replace existing bundled InfluxDB data / [a] abort
- Only bundled mode is restorable; external mode is rejected.
- An encrypted source archive prompts for its password.
- A rollback InfluxDB backup can be created first, and can itself be encrypted. If rollback creation fails, the restore does not start (unless you chose no rollback).
- The MVP supports replace-style restore only — no merge of buckets, tasks
or history. Non-interactive restores must pass
--on-conflict replaceto confirm the destructive replace. --fullrestores InfluxDB metadata (org, buckets, users, tokens, dashboards) and time-series data. Restoring a backup taken from the same bundled instance keepsdeploy/docker/influxdb.envin sync; restoring one from a different instance may require updating the token.
After an InfluxDB restore the CLI recommends:
python3 emsctl.py influx status
python3 emsctl.py diagnose --deep
Because influx restore --full replaces InfluxDB org/buckets/users/tokens as
well as history, restore in this order so the bundled token and config agree:
-
Restore the config backup first (
ems-config-...) — brings backconfig.jsonand the bundled InfluxDB secret (deploy/docker/influxdb.env). -
Verify the bundled InfluxDB secret/config files are present (the env file exists and
config.jsonhasinfluxdb.enabled: true,mode: bundled). -
Restore the InfluxDB backup (
ems-influxdb-...) — replace-style restore of analytics history (bundled mode only; external mode is not supported). -
Verify:
python3 emsctl.py influx status python3 emsctl.py diagnose --deep
Restore a database backup (ems-databases-...) at any point in this sequence;
it is independent of the InfluxDB data. A rollback backup before the InfluxDB
restore is strongly recommended, and encrypted source backups prompt for the
restore password.
The CLI rejects invalid input without changing the file:
- unknown device
- negative watt values
- missing or invalid
pv-priority-factor pv-priority-factor < 0.01loop_interval <= 0- invalid offgrid value; allowed values are
off,eco, andstandard - invalid runtime-state JSON
- unknown command
- dashboard password confirmation mismatch
- wrong dashboard current password
Examples:
python3 emsctl.py device UNKNOWN disable
python3 emsctl.py system max-power -1
python3 emsctl.py device WR1 pv-priority-factor 0
python3 emsctl.py device WR1 offgrid maybeThe CLI writes via a temporary file and atomic rename:
data/runtime-state.json.<pid>.tmp -> data/runtime-state.json
This keeps runtime-state edits robust even when the EMS is running.