Cross-platform desktop monitor for EcoFlow power stations. Real-time telemetry via BLE (local) or MQTT (cloud), configurable automation rules, and persistent history. No developer API key required — authenticates with your regular EcoFlow account.
Status: Confirmed working on EcoFlow Delta 3 (BLE + MQTT). Other EcoFlow devices using the
pd335_sysprotobuf should also work.
Pre-built, self-contained binaries are published on the Releases page. No .NET runtime install required.
| Platform | File |
|---|---|
| Windows x64 | EcoFlowMonitor-vX.Y.Z-win-x64.zip |
| Linux x64 | EcoFlowMonitor-vX.Y.Z-linux-x64.tar.gz |
| macOS (Apple Silicon) | EcoFlowMonitor-vX.Y.Z-osx-arm64.zip |
| macOS (Intel) | EcoFlowMonitor-vX.Y.Z-osx-x64.zip |
Binaries are unsigned. First-run workarounds:
- macOS (Gatekeeper):
xattr -cr /path/to/EcoFlowMonitor.App.appafter unzipping. - Windows (SmartScreen): click More info → Run anyway on the warning dialog.
- Linux: make sure
bluetoothdis running and your user is in thebluetoothgroup. Mark the binary executable withchmod +x EcoFlowMonitor.Appif needed.
EcoFlowUPS/
├── src/ Avalonia desktop app (.NET 10, cross-platform)
│ ├── EcoFlowMonitor.sln
│ ├── EcoFlowMonitor.App/ Avalonia UI, ViewModels, services
│ ├── EcoFlowMonitor.Core/ Protocol, models, MQTT + BLE clients
│ ├── EcoFlowMonitor.Cli/ Diagnostic CLI (raw MQTT dump)
│ ├── EcoFlowMonitor.Platform.Windows/ WinRT BLE, toasts, Task Scheduler
│ ├── EcoFlowMonitor.Platform.macOS/ CoreBluetooth BLE, osascript, LaunchAgent
│ └── EcoFlowMonitor.Platform.Linux/ BlueZ BLE, libnotify, systemd user units
├── poc/ Python reference implementation (protocol notes)
├── CLAUDE.md AI-assistant context for this repo
└── README.md You are here
.NET 10 + Avalonia UI. Runs on Windows, macOS, and Linux with a shared core and per-platform adapters (tray icon, notifications, BLE backend, power actions, autostart).
Features:
- BLE channel: ECDH SECP160r1 handshake → AES-128-CBC session →
pd335_sys.DisplayPropertyUploadprotobuf - MQTT channel: Cloud broker at
mqtt.ecoflow.com:8883using credentials fetched via the REST login flow - History: SQLite-backed time series of every telemetry snapshot and power event, visible in the History view
- Automation: full rules engine, cross-platform. In-app editor on the dashboard — + Add Rule / Rule History buttons at the top-right of each device card; per-rule Test, Edit, Delete controls on the list below. Triggers + actions are summarised in the tables further down.
- Audit log: every rule firing and per-action outcome persisted in
history.db, viewable via the Rule History button. 30-day retention by default; configurable. - Concurrency cap: actions dispatch through a bounded channel with a user-configurable limit (default 8, range 1–64) so runaway scripts or hung webhooks can't block telemetry ingestion.
Build a local release:
Helper scripts in scripts/ produce a self-contained, ready-to-ship binary for each OS. They mirror exactly what CI does.
# macOS (host arch auto-detected; produces signed .app + zip in dist/)
./scripts/build-macos.sh 1.0.0
# Linux (produces single-file ELF + tar.gz in dist/)
./scripts/build-linux.sh 1.0.0
# Windows (PowerShell — produces single-file .exe + zip in dist/)
./scripts/build-windows.ps1 -Version 1.0.0Pass just ./scripts/build-macos.sh (no args) for a 0.0.0-local dev build.
Quick iteration (no packaging):
# macOS (Apple Silicon) — Debug build, launch in place
dotnet build src/EcoFlowMonitor.App/EcoFlowMonitor.App.csproj -f net10.0-macos -c Debug -r osx-arm64
open src/EcoFlowMonitor.App/bin/Debug/net10.0-macos/osx-arm64/EcoFlowMonitor.App.app
# Windows
dotnet build src/EcoFlowMonitor.sln -c Release
# Linux
dotnet build src/EcoFlowMonitor.App/EcoFlowMonitor.App.csproj -f net10.0 -c ReleaseConfig: written by the in-app Settings screen. The app does not read .env or environment variables.
| OS | Location |
|---|---|
| Windows | %AppData%\EcoFlowMonitor\config.json |
| macOS | ~/Library/Application Support/EcoFlowMonitor/config.json |
| Linux | ~/.config/EcoFlowMonitor/config.json |
Standalone scripts that document the EcoFlow REST API, MQTT protocol, and BLE protocol. Useful for verifying credentials or exploring wire formats before debugging the C# app. See poc/README.md for setup (uses its own poc/config.json).
- Auth: REST login at
api.ecoflow.com/auth/loginreturns a JWT + user ID - MQTT creds:
api.ecoflow.com/iot-auth/app/certificationexchanges the JWT for MQTT username/password - Broker:
mqtt.ecoflow.com:8883(TLS, self-signed cert) - Topic:
/app/device/property/{serialNumber} - Encoding: Protobuf binary with a custom outer envelope; inner payload XOR-encrypted when
encType == 1andsrc != 32(key =seq & 0xFF)
- Advertisement: Service UUID
0000FFF0-0000-1000-8000-00805F9B34FB, manufacturer ID46517 - GATT: Nordic UART — notify
00000003-…, write00000002-… - Handshake: ECDH SECP160r1, session key derived via embedded
keydata.b64lookup table + MD5 - Transport: AES-128-CBC with PKCS7 padding; frame type
0x01for post-handshake packets - Auth:
MD5(cloud_user_id + device_sn)as ASCII-hex oncmdSet=0x35, cmdId=0x86 - Data:
pd335_sys.DisplayPropertyUploadonsrc=0x02, cmdSet=0xFE, cmdId=0x15; payload XOR-decoded withseq[0]before protobuf parse
No official SDK — all of the above is from packet capture and the ha-ef-ble Home Assistant integration.
Rules combine one or more conditions via a single operator — All must be true (AND) or Any must be true (OR) — and fire once on the rising edge of the combined predicate, throttled by a per-rule cooldown (default 300 s).
Example: "shut down my PC when the battery is below 20% and AC is out" is a 2-condition AND rule with a Shutdown action. Charging down to 15% while plugged in no longer triggers it.
Under the hood, every condition is a trigger — the 13 types below. Edge-style triggers (PowerLost, AcPlugged, DeviceOffline…) contribute their current state inside a composite; the rising edge is detected on the overall predicate, not per condition. A condition whose source telemetry is missing evaluates to false so the rule never fires on "unknown" data.
A rule with exactly one condition behaves identically to the pre-composite engine, and legacy config.json files migrate into this shape automatically on load.
| Trigger | Kind | Parameter | Description |
|---|---|---|---|
PowerLost |
edge | — | Station transitions into PowerLost (AC disappeared). |
PowerRestored |
edge | — | Station transitions out of PowerLost back to Charging. |
AcPlugged |
edge | — | AC cable plugged into the station (false → true on AcPluggedIn). |
AcUnplugged |
edge | — | AC cable unplugged from the station. |
DeviceOnline |
edge | — | First telemetry after a DeviceOffline gap resolves. |
BatteryBelow |
level | % (0–100) |
Battery percentage below threshold. |
BatteryAbove |
level | % (0–100) |
Battery percentage above threshold. |
TimeRemainingBelow |
level | min (1–1440) |
Device-reported minutes-to-empty below threshold. |
TempAbove |
level | °C (decimal) |
BMS temperature above threshold. |
TempBelow |
level | °C (decimal) |
BMS temperature below threshold. |
InputWattsBelow |
level | W |
Total input watts below threshold (handy for solar drop-off). |
OutputWattsAbove |
level | W |
Total output watts above threshold (heavy-load alert). |
DeviceOffline |
edge | sec (30–86400) |
No telemetry on any channel for the configured window (default 300 s). |
Actions dispatch through a bounded concurrency queue (default 8 in-flight). Each attempt lands in the audit log with its outcome — success / failure / skipped / timeout / dropped.
| Action | Windows | macOS | Linux |
|---|---|---|---|
Shutdown |
shutdown.exe /s /t 0 |
osascript … shut down |
systemctl poweroff |
Hibernate |
shutdown.exe /h |
pmset sleepnow |
systemctl hibernate |
Sleep |
rundll32.exe … SetSuspendState |
pmset sleepnow |
systemctl suspend |
RunScript |
.bat, .ps1, .exe |
shell / exec | shell / exec |
RunCommand |
cmd.exe / pwsh (per-action shell picker) |
/bin/sh -c "…" |
/bin/sh -c "…" |
Webhook |
HTTP POST/PUT with user headers + optional body template; per-action timeout + configurable retries (0–5) | same | same |
Notification |
Toast | osascript display notification |
notify-send |
WriteLog |
timestamped append | timestamped append | timestamped append |
RunCommand takes per-OS command strings (commandWindows / commandMacOS / commandLinux) so one rule runs the right thing on whichever host the app is launched on. If the field for the current OS is empty, the action is marked skipped (no failure, no crash).
Webhook: Authorization and X-*-Token / X-*-Secret / X-*-Key headers are redacted in the audit log; URL and body template are stored verbatim (keep secrets in headers, not in URLs).
Template variables — expanded in any templated field (notification title/body, log message, webhook body, RunCommand arguments). Unknown / missing values expand to <unknown> (? for legacy variables, preserved for backward compatibility).
| Variable | Source |
|---|---|
{device} |
Device display name |
{device_sn} |
Device serial number |
{battery} |
Battery percent (1 decimal, InvariantCulture) |
{remain} |
Estimated runtime remaining (Xh Ym) |
{status} |
Charging / Idle / PowerLost / Unknown |
{in_w} |
Total input watts |
{out_w} |
Total output watts |
{temp_c} |
BMS temperature °C (1 decimal, InvariantCulture) |
{ac_plugged} |
true / false — AC plugged-in state |
{charge_state} |
Raw EMS charge-state integer |