Skip to content

Latest commit

 

History

8 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Inverter WiFi v2

🇬🇧 English (this file) · 🇺🇦 Українською

WiFi monitor and controller for Voltronic‑family hybrid inverters (Powmr / PI18 / PI30), with native JK‑BMS Bluetooth integration, a persistent fault log, MQTT/Home Assistant support, and installable PWA web UI — on a single ESP32‑C3 SuperMini.

Platform: ESP32‑C3 Framework: Arduino Built with PlatformIO License: MIT

The goal of the project is full parity with the inverter's physical settings panel over a web UI — so you can monitor live data and change every battery/charge/priority setting from your phone or PC, without ever touching the unit. A coulomb‑counting JK‑BMS is read over BLE in parallel, giving accurate SOC and per‑cell health that the inverter alone can't provide. On top of that it adds things the inverter's own LCD never had: a persistent fault history, long‑term energy graphs, MQTT/Home Assistant integration, and external fault notifications — closing most of the gap with commercial monitoring boxes (Solar Assistant, EG4 Monitor, WatchPower) on a $3 module.


Screenshots


Dashboard — animated energy‑flow diagram + BMS card

Monitor — full numeric telemetry, BMS cells, protection thresholds

Settings — source priorities, AC output, LiFePO4 presets

History — interactive chart, grid‑energy summary

System — device info, warnings reference list

Diagnostics — raw command console + live device log

More screens (battery voltages/currents/BMS cross‑reference, device flags & calibration, WiFi/BMS/notification/MQTT setup, the OTA updater) are in screenshots/.


Table of contents


Features

Inverter

  • Automatic protocol detection — probes PI30 and PI18 at boot and picks the one the connected unit speaks.
  • Per‑session capability probe — discovers which optional command groups the unit actually answers (QMOD, QPIRI, QPIWS, QFLAG, QPIGS2, Q1, QET/QED, QBEQI, PI18's FWS…) and hides UI sections / skips polls the device ignores.
  • Live telemetry — grid, AC output, load, battery, dual‑MPPT PV, temperatures, charge stage, energy counters.
  • Fault & warning decoding for both protocols — PI30's QPIWS (all bits, including the less‑common bits 24‑29) and PI18's FWS — prioritized in the poll cycle so a fault shows up within ~1 s, not buried behind a round‑robin of less urgent queries.
  • Full settings control — output/charge source priority, battery type & all LiFePO4 voltage thresholds, max charge currents, input range, flags (buzzer, bypass, restart‑on‑overload/overtemp, etc.).
  • Diagnostic console — send any raw query (e.g. Q1, QPIGS) and see the exact device reply; battery‑voltage calibration via BTA.
  • Non‑blocking & task‑safe — the slow 2400‑baud link is owned by a single task; the async web handlers only queue commands and read buffered results.

Battery / JK‑BMS (BLE)

  • Reads the JK02_32S cell‑info frame over NimBLE in its own FreeRTOS task (a blocking BLE connect never stalls polling or the web server).
  • Coulomb‑counted SOC, remaining/full Ah, pack V/A/W (signed), temperatures, cycle count, and per‑cell voltages with min/max/avg/spread.
  • Battery runtime estimate — remaining Ah ÷ current load, shown live on the dashboard.
  • BMS data flows to the UI even when no inverter is connected.
  • MAC and enable‑state are stored in NVS and editable at runtime — turn polling off to free the BMS for the phone app, no reflash or reboot.

Fault history & alerting

  • Persistent, timestamped fault/event log (survives reboots and power loss) — every fault transition is latched with wall‑clock time, browsable in the UI, not just "current state."
  • External notification webhook (Telegram / ntfy.sh / Home Assistant / anything that accepts a GET or POST) fires automatically the moment a fault starts — see Fault log & external notifications.

History & energy

  • Three‑tier on‑flash history (1‑min / 15‑min / 2‑hour resolution, covering 24 h / 30 days / ~1 year respectively) with an interactive, hand‑drawn SVG chart (hover/tap crosshair, zoomable ranges, grid‑outage shading).
  • Daily/monthly PV & load energy view with a bar chart, computed client‑side from the same history data.
  • CSV export of the history tiers for offline analysis in Excel/Sheets.

Networking

  • STA + AP fallback: joins your saved WiFi as a client; if none is stored or reachable, raises its own Inverter config AP with a live network scan + password entry. Saving reboots straight into STA.
  • mDNS (http://inverter.local) and a DHCP hostname (inverter) so you don't have to chase the IP.
  • WiFi+BLE radio coexistence handled correctly on the single‑radio C3 (modem‑sleep kept on; BLE paused during a WiFi scan).
  • OTA updates over the web UI — upload a new firmware or web‑interface (LittleFS) .bin from System → Firmware Update; no USB cable needed after the first flash.
  • MQTT bridge with Home Assistant Auto‑Discovery — the inverter shows up as a device in HA with zero YAML editing; see MQTT / Home Assistant integration.

Web UI

  • Single self‑contained index.html (HTML/CSS/JS, no build step), served from LittleFS.
  • Animated Home‑Assistant‑style energy‑flow dashboard, bilingual 🇺🇦 Ukrainian / 🇷🇺 Russian.
  • Live updates over WebSocket; REST for everything else.
  • Installable as a home‑screen app (PWA) — manifest + icons let Android/iOS add it without keeping a browser tab open; see Install as an app (PWA).

Hardware

Part Notes
ESP32‑C3 SuperMini Single‑core RISC‑V, shared WiFi+BLE radio. Board profile: esp32-c3-devkitc-02.
Voltronic‑family inverter e.g. Powmr POW‑HVM2H‑12V‑N (12 V / 1.6 kW). Must speak PI30 or PI18 ASCII.
JK‑BMS Hardware ≥ v11 (JK02_32S protocol). Tested on a 4S LiFePO4 pack.
RS232 ↔ TTL level shifter e.g. MAX3232 — the inverter's comms port uses RS232 levels; the ESP32 UART is 3.3 V TTL. Do not connect directly. Verify your port's levels before wiring.

Wiring (UART to inverter)

ESP32‑C3 Direction Inverter (via MAX3232)
GPIO20 (RX) ◀── TX
GPIO21 (TX) ──▶ RX
GND ─── GND

UART: 2400 baud, 8N1. BMS link is wireless (BLE) — no wiring.


Architecture

                ┌──────────────────────── ESP32-C3 SuperMini ────────────────────────┐
   Inverter ────┤ UART1 2400 8N1   ┌──────────────┐                                   │
  (PI30/PI18)   │  GPIO20 RX  ────▶│ InverterMgr  │ realtime / settings / info / caps │
  via MAX3232   │  GPIO21 TX       │  (loop task) │──┐                                 │
                │                  └──────────────┘  │                                 │
   JK-BMS ──────┤ BLE / NimBLE     ┌──────────────┐  ▼   ┌────────────┐  WS  /ws       │
  (JK02_32S)    │  own task   ────▶│  BmsManager  │─────▶│ WebServer  │── REST /api/* ─┼──▶ Browser / PWA
                │                  └──────────────┘  ▲   └────────────┘  static (FS)   │   (UA/RU UI)
                │ WiFi STA or AP   ┌──────────────┐  │        │  │                     │
                │  shared radio ──▶│ WifiManager  │──┘        │  └─▶ FaultLog (NVS/FS,  │
                │                  └──────────────┘           │      timestamped log)  │
                │                                              ├─▶ HistoryLog (LittleFS,│
                │                                              │   3-tier ring buffer)  │
                │                                              ├─▶ Notifier (HTTP        │
                │                                              │   webhook on fault)    │
                │                                              └─▶ MqttBridge (HA        │
                │                                                  discovery + state)   │
                └─────────────────────────────────────────────────────────────────────┘

Task model. Everything that touches the slow serial port runs in the Arduino loop() task. The async web server (AsyncTCP) task never touches the radio or serial directly — it queues commands and reads mutex‑/queue‑buffered results. The JK‑BMS BLE link runs in its own dedicated FreeRTOS task. External notifications fire from a short‑lived FreeRTOS task so a slow/unreachable webhook can never stall the poll loop. This keeps each shared resource single‑owner and collision‑free.

Protocol layer. InverterProtocol is an abstract interface (probe, readRealtime, readSettings, readInfo, probeCapabilities, readExtended, readEnergy, sendCommand, rawQuery). PI30Protocol and PI18Protocol implement it; InverterManager auto‑selects at boot and re‑probes if detection fails. PI ASCII framing/CRC (CRC‑16/XMODEM) lives in util/InverterCRC.h.

Polling cadence. Realtime ≈ 1 s, settings ≈ 30 s, energy counters ≈ 60 s. Fault/warning status (QPIWS/FWS) is polled with priority so a new fault surfaces within about a second rather than waiting behind other extended‑group queries; the remaining extended groups (PV2 / Q1) are round‑robined across cycles to spread the slow 2400‑baud traffic. Data is "online" if refreshed within 5 s.


Web UI

Six tabs, all driven by the live WebSocket stream:

Tab What it shows
Dashboard Animated energy‑flow diagram (PV · Grid · Inverter · Load · Battery) with consolidated power values per node, battery charge ring, SOC, remaining‑runtime estimate, active‑fault banner.
Monitor Full numeric telemetry — voltages, currents, power, temperatures, PV, energy, per‑cell BMS voltages.
Settings Every writable inverter setting + LiFePO4 charge presets + battery‑voltage calibration (with live inverter‑vs‑BMS Δ helper) + MQTT + fault notification webhook configuration.
History Interactive 24 h/30 d/1 yr charts (SOC, battery V, PV, load, grid V), daily/monthly PV & load energy bars, CSV export.
System Device info (serial, firmware, model, protocol), WiFi status, BMS Bluetooth config, OTA firmware/filesystem update, restart/forget‑WiFi.
Diagnostics Raw command console, persistent fault/event log, last raw BMS frame (hex) for offset verification, live device log (mirrors Serial over WiFi).

Language toggles between Ukrainian and Russian; all strings are in the i18n tables in index.html.


LiFePO4 presets & BMS safety

The inverter has no native LiFePO4 profile, so each preset drives User mode (PBT02) and writes the full voltage set — bulk/CV, float, recharge, re‑discharge and low‑voltage cutoff — scaled per cell to the detected pack (4S @ 12 V, 8S @ 24 V, 16S @ 48 V).

Preset Charge depth Use case
Full ~95 % Maximum usable capacity
Balanced ~90 % Daily use / good compromise
Gentle ~85 % Reduced stress
Long‑life ~80 % Maximum cycle life

Key safety principle: every preset value is kept strictly inside the JK‑BMS protection window, with margin, so the inverter always acts before the BMS trips. A BMS protection trip is an emergency disconnect — it must never happen under normal, correct settings. Because the inverter runs these thresholds open‑loop on its own voltmeter, the Settings tab includes a live inverter‑vs‑BMS voltage delta and a BTA calibration helper that recommends the exact step count to make them agree (only when current ≈ 0, so cable I·R drop doesn't skew the reading). The Settings tab also cross‑references every inverter setpoint against the BMS's own per‑cell protection thresholds (scaled to the pack) with a live ok/warn/bad margin verdict.

The shipped preset values are tuned for a pack whose measured JK‑BMS limits are Cell OVP 3.50 V / Cell UVP 2.70 V. If your BMS protection thresholds differ, adjust LFP_PRESETS in data/index.html accordingly.


Fault log & external notifications

Persistent fault log (src/log/FaultLog.h/.cpp) latches every fault/warning bit transition with a wall‑clock timestamp (via NTP, see below) so you can see what happened and when, even after a reboot or a power outage — the inverter's own LCD only ever shows the current state. Browsable from Diagnostics in the web UI, and available as GET /api/faults.

External notification webhook (src/net/Notifier.h/.cpp) fires once, automatically, the moment the active‑fault mask goes from clear to non‑clear (not on every poll) — configured under Settings → Fault Notification (enable toggle, target URL, a "Send Test" button that doesn't require enabling the trigger first). The URL supports a {msg} placeholder for a GET request, or is POSTed the raw message otherwise.

This is deliberately a plain‑HTTP‑only client (a hand‑rolled WiFiClient request, not HTTPClient/WiFiClientSecure) — adding real TLS support was measured at ~56 KB of flash, more than the entire remaining headroom on this board. Since Telegram's Bot API and most public push services are HTTPS‑only, point the webhook at a local plain‑HTTP relay instead of calling them directly:

  • Home Assistant: create an automation with a webhook trigger; the automation itself calls the Telegram/ntfy.sh/whatever integration over HTTPS from HA's side. Zero new code, and if you're already using the MQTT integration below, this is a couple of YAML lines.
  • ntfy.sh self‑hosted / any small relay script (Node‑RED, a Raspberry Pi cron+webhook script, a cheap cloud function) works the same way.

A full two‑way Telegram bot (commands like /status) is intentionally not built into the firmware — it would need on‑device HTTPS plus long‑polling, which doesn't fit the flash/CPU budget of a $3 module. The architecturally sound way to add one is a small off‑device companion service that talks to Telegram and simply calls this device's existing local REST API (GET /api/status etc.) — see the roadmap.


History, energy graphs & CSV export

src/log/HistoryLog.h/.cpp implements a fixed‑size, power‑gap‑safe 3‑tier RRD‑style ring buffer on LittleFS, independent of the ~1.31 MB OTA app partition (which stays free for code):

Tier Resolution Retention Records
0 1 minute 24 hours 1440
1 15 minutes 30 days 2880
2 2 hours ~1 year 4380

Each 18‑byte record stores timestamp, mode/charge‑state flags, battery V/I, SOC, PV/load power, grid V, and temperatures. Because the ESP32 has no RTC, timestamps come from NTP (pool.ntp.org, synced once WiFi connects) — sampling is gated on having a valid time, so no garbage epoch‑0 records get logged before the first sync.

The History tab renders this with a hand‑rolled SVG chart (no external charting library) — zoomable 1 h/24 h/30 d/1 yr ranges, a hover/tap crosshair with an exact‑value tooltip, gridlines, and shaded spans for grid‑outage periods. A second card estimates daily/monthly PV & load energy (Wh/kWh) by integrating the same tier data client‑side, gap‑capped so a power outage in the log doesn't inflate the numbers. Both tiers can be exported as CSV for deeper analysis in a spreadsheet.


MQTT / Home Assistant integration

src/net/MqttBridge.h/.cpp publishes state to any MQTT broker (e.g. Mosquitto, including the official Home Assistant add‑on) over plain TCP (no TLS — brokers on a trusted LAN default to plain 1883 anyway, and this keeps the flash footprint tiny at ~2.7 KB for the whole client). Configure it under Settings → MQTT (Home Assistant): enable, host, port, username, password.

Once connected, it publishes Home Assistant MQTT Discovery configs (retained) for SOC, battery voltage/current, PV power, load power, grid voltage, operating mode, and a fault binary‑sensor — the inverter shows up automatically as one device under Settings → Devices & Services → MQTT in HA, no configuration.yaml editing required. State is republished periodically while connected.

This effectively gives you full mobile access (widgets, history, push notifications) for free through the Home Assistant Companion app, without building or maintaining a separate native Android app for this project.


Install as an app (PWA)

The web UI ships a manifest.json + app icons (data/icons/) and a minimal service worker (data/sw.js), so it can be added to your phone's home screen and launched like a standalone app — no address bar, no browser tabs to keep around.

  • Android (Chrome/Samsung Internet/Firefox): open the dashboard, then use the browser menu → Add to Home screen.
  • iOS (Safari): ShareAdd to Home Screen.

One caveat worth knowing: this device serves plain HTTP on your LAN (http://inverter.local or a bare IP), and browsers only treat https:// or http://localhost as a "secure context." That doesn't block the manifest/icon‑driven home‑screen shortcut itself, but it does mean the service worker won't register (no offline caching, no automatic install prompt) unless you put this device behind an HTTPS reverse proxy (e.g. Home Assistant ingress, Nginx, Caddy) at some point — the service worker is a deliberate no‑op pass‑through today (see the comment in data/sw.js), so nothing about the live device data is cached or can go stale.

If you'd rather have deeper phone integration (widgets, push, history) than a home‑screen shortcut can offer, see MQTT / Home Assistant integration above — the HA Companion app covers that ground already.


Build & flash

Requires PlatformIO (CLI or the VS Code extension).

# Build firmware
pio run -e esp32-c3-supermini

# Flash firmware
pio run -e esp32-c3-supermini -t upload

# Flash the web UI (LittleFS image built from data/)
pio run -e esp32-c3-supermini -t uploadfs

# Serial monitor (115200 baud)
pio device monitor -b 115200

On Windows PowerShell, if pio isn't on PATH, call it directly: & "$env:USERPROFILE\.platformio\penv\Scripts\platformio.exe" run -e esp32-c3-supermini

After changing anything under data/, re‑run -t uploadfs (and hard‑refresh the browser with Ctrl+F5).

Dependencies (auto‑installed from platformio.ini): ESPAsyncWebServer · AsyncTCP · ArduinoJson · NimBLE‑Arduino · PubSubClient.


First boot & WiFi setup

  1. Power up. With no stored WiFi, the board hosts an access point:
    • SSID: Inverter Password: 12345678 IP: http://192.168.4.1
  2. Open the portal, scan, pick your network, enter the password, Save. The board reboots and joins your LAN as a client.
  3. Reach it afterwards via http://inverter.local (mDNS) or the IP your router assigned. Tip: set a DHCP reservation for a stable address.
  4. In System → Bluetooth BMS, set your JK‑BMS MAC (visible in the JK app) and enable polling.
  5. Optional: configure MQTT and/or the fault notification webhook under Settings.

The first‑boot BMS MAC default lives in src/main.cpp (BMS_MAC_DEFAULT); after first boot the NVS value wins and is editable from the UI.


OTA firmware update

Once the device is on your WiFi, you never need USB again — new firmware and UI updates can be pushed straight from the browser.

  1. Build the artifact you want to deploy with PlatformIO (don't flash it over USB, just build):
    # Firmware
    pio run -e esp32-c3-supermini
    # → .pio/build/esp32-c3-supermini/firmware.bin
    
    # Web interface (LittleFS image built from data/)
    pio run -e esp32-c3-supermini -t buildfs
    # → .pio/build/esp32-c3-supermini/littlefs.bin
  2. Open the dashboard → System → Firmware Update (OTA).
  3. Pick the Target (Firmware or Web interface (filesystem)), choose the matching .bin, and hit Upload.
  4. A progress bar tracks the upload; the device flashes it to the inactive OTA slot as bytes arrive and reboots automatically on success. The page reconnects on its own once it's back.

Notes:

  • Uploading the wrong .bin to a target is caught safely — the ESP32 Update library validates the firmware image header and partition size before committing, so a bad upload just fails with an error toast; it can't half‑brick the running image (the previous OTA slot is untouched until the new one verifies).
  • The two update paths are independent REST endpoints (POST /api/ota/firmware, POST /api/ota/fs) that stream the multipart upload straight into the flash — see REST API & WebSocket.
  • Flash is nearly full: ~94.3% of the 1.31 MB OTA app partition on the stock 4 MB board (~75 KB free) — check pio run size output before adding new C++ features; put anything sizeable in data/index.html (JS) instead, since the LittleFS partition still has plenty of room.

REST API & WebSocket

Real‑time data is pushed over WebSocket /ws as JSON (t:"rt" snapshots merging inverter + BMS; t:"cmd" for command results; t:"ota" for OTA start/progress/done/error events). REST endpoints:

Method Path Purpose
GET /api/status Current realtime snapshot (inverter + BMS)
GET /api/settings Inverter settings / ratings
GET /api/info Device serial / firmware / model / protocol
GET /api/caps Per‑session capability map
GET /api/history?tier=0|1|2 Binary history dump for a tier (see History)
GET /api/faults Persistent fault/event log
GET/POST /api/notifycfg Fault‑notification webhook config — {enabled,url}
POST /api/notifytest Send a test notification — {url?}
GET/POST /api/mqttcfg MQTT broker config — {enabled,host,port,user,pass?}
GET /api/log Live device log (mirrors Serial over WiFi)
POST /api/set Send a setting command — { "cmd": "POP00" }
POST /api/raw Diagnostic query (raw reply over WS) — { "cmd": "Q1" }
GET/POST /api/bmscfg BMS config — {enabled, mac}
GET /api/bmssettings Last BMS settings/protection‑thresholds frame
GET /api/bmsraw Last validated JK cell‑info frame (hex)
GET /api/wifi WiFi status (mode, connected, ssid, ip, rssi, host)
POST /api/wifi Save credentials & reboot into STA — { "ssid": "...", "pass": "..." }
GET /api/wifi/scan Async scan; poll until scanning:false
POST /api/wifi/forget Clear credentials & reboot into AP config
POST /api/ota/firmware OTA: multipart‑upload a firmware .bin; reboots on success
POST /api/ota/fs OTA: multipart‑upload a LittleFS .bin (web UI); reboots on success

Route order matters: ESPAsyncWebServer matches paths by prefix, so the specific /api/wifi/* routes are registered before the bare /api/wifi.


Project layout

.
├── data/
│   ├── index.html               # Single-file web UI (served from LittleFS)
│   ├── manifest.json            # PWA manifest (home-screen install)
│   ├── sw.js                    # Minimal pass-through service worker
│   └── icons/                   # PWA icons (192/512/512-maskable/favicon)
├── tools/
│   └── make_icons.py            # One-off script that generated data/icons/
├── src/
│   ├── main.cpp                 # Wires all managers; setup() / loop()
│   ├── inverter/
│   │   ├── InverterProtocol.h   # Abstract protocol interface
│   │   ├── InverterManager.*    # Auto-detect, non-blocking poll, command queue
│   │   ├── InverterData.h       # Realtime / Settings / Info / Caps structs + JSON
│   │   ├── PI30Protocol.*       # PI30 ASCII implementation
│   │   └── PI18Protocol.*       # PI18 ASCII implementation (incl. FWS warnings)
│   ├── bms/
│   │   ├── BmsManager.*         # JK-BMS BLE link (NimBLE) in its own task
│   │   └── BmsData.h            # BMS snapshot struct + JSON
│   ├── log/
│   │   ├── FaultLog.*           # Persistent, timestamped fault/event log
│   │   └── HistoryLog.*         # 3-tier RRD-style ring buffer on LittleFS
│   ├── net/
│   │   ├── WifiManager.*        # STA/AP, scan state machine, mDNS, NTP, deferred reboot
│   │   ├── Notifier.*           # Fault webhook (plain HTTP, fire-and-forget task)
│   │   └── MqttBridge.*         # MQTT + Home Assistant Auto-Discovery
│   ├── web/
│   │   └── WebServer.*          # REST endpoints + WebSocket + static files
│   └── util/
│       ├── InverterCRC.h        # CRC-16/XMODEM for PI frames
│       └── RemoteLog.*          # Serial mirror -> RAM ring buffer + WS/REST
├── platformio.ini
└── .gitignore

Safety

⚠️ This project controls a device wired to mains AC and a high‑current battery.

  • Inverter comms ports use RS232 voltage levels — connecting an ESP32 GPIO directly can damage it. Use a proper level shifter.
  • Changing charge/discharge settings on a real battery has real consequences. Keep preset values inside your BMS protection window and verify them against your own BMS configuration.
  • The BMS is your last line of defence; never rely on the inverter alone for cell protection.
  • Use at your own risk. No warranty — see the license.

Roadmap / known gaps

  • PI18 settings‑write parity. The Settings UI currently emits PI30 command strings; PI18 write parity is the main outstanding gap (read path, including warnings via FWS, works for both protocols).
  • PI18 FWS fault/warning parsing is not from an official protocol document — it's corroborated by an independent open‑source PI18 reverse‑engineering project, not Voltronic's own PDF. High confidence, but not yet confirmed against real PI18 hardware (the bench unit used to develop this project is PI30).
  • Optional: a small off‑device companion service for a two‑way Telegram bot (/status, etc.) that calls this device's existing local REST API — deliberately not built into the firmware itself, see Fault log & external notifications.
  • Optional: HA‑style gauges above the flow diagram; live voltmeter‑drift logging.
  • Flash headroom on the app partition is tight (~75 KB) — future firmware‑side features should be measured for actual linked cost before committing (see the MQTT/Notifier stories above for why "looks small" isn't a reliable predictor).

License & credits

Released under the MIT License.

About

ESP32-C3 WiFi monitor/controller for Voltronic/Powmr PI18/PI30 hybrid inverters + JK-BMS BLE: animated web UI (installable as a PWA), OTA updates, persistent fault log, MQTT/Home Assistant integration, long-term history graphs, LiFePO4 presets

Topics

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages