solarflow-ble
Async Python library for Zendure SolarFlow devices over Bluetooth Low Energy. Requires Python >= 3.14.
Unofficial and reverse-engineered: not endorsed by Zendure, and it may break without notice whenever Zendure changes their firmware.
The protocol work builds on esphome-solarflow-ble and reverse engineering of the Zendure Android app. The library has been tested with a SolarFlow 2400AC through a Home Assistant Connect AUX-2 Bluetooth proxy.
Install
uv add solarflow-ble
Documentation
The API reference is hosted at solarflow-ble.readthedocs.io.
Usage
SolarFlowClient accepts an injected BleTransport. Tests can use a fake
transport without Bluetooth hardware.
import asyncio
from bleak import BleakScanner
from solarflow_ble import BleakTransport, SolarFlowClient
async def main() -> None:
device = await BleakScanner.find_device_by_address("AA:BB:CC:DD:EE:FF")
if device is None:
raise SystemExit("SolarFlow not found")
async with SolarFlowClient(BleakTransport(device)) as client:
print(client.device_id)
print(client.state)
asyncio.run(main())
Inside Home Assistant, pass the BLEDevice from
bluetooth.async_ble_device_from_address instead of scanning.
parse_advertisement turns SolarFlow manufacturer data into an
Advertisement with the device identifier.
The async with block connects on entry and always disconnects on exit,
including when the body raises. For reconnect use cases, call connect()
and disconnect() yourself instead; connect() is reusable after
disconnect() on the same client instance.
The client waits for BLESPP, sends BLESPP_OK, requests getInfo, then
sends a read request for getAll. client.status moves from
DISCONNECTED to CONNECTED, PROTOCOL_READY after getInfo-rsp, and
READY after the first report. connect() returns once the session is
READY. Every later report updates client.state and is passed to the
optional update_callback as a SolarFlowUpdate.
Controls
Control methods raise SolarFlowNotReadyError unless the client was built
with allow_control=True and the session is READY.
from solarflow_ble import MODEL_SOLARFLOW_2400AC, AcMode
async with SolarFlowClient(
BleakTransport(device), allow_control=True, model=MODEL_SOLARFLOW_2400AC
) as client:
await client.set_output_limit(800)
await client.set_ac_mode(AcMode.DISCHARGING)
| Method | Value | Default range |
|---|---|---|
set_input_limit |
watts | 0..2400 |
set_output_limit |
watts | 0..2400 |
set_min_soc |
percent | 0..50 |
set_soc |
target percent | 70..100 |
set_ac_mode |
AcMode.CHARGING (1) or AcMode.DISCHARGING (2) |
Out-of-range values raise SolarFlowValidationError before anything is
sent. A rejected write raises SolarFlowCommandError. The SOC setters take
percent, but state.min_soc and state.soc_set hold the raw per-mille wire
value, so a reported 500 means 50%. All exceptions derive from
SolarFlowError.
Model limits
The default ranges are the values verified for the SolarFlow 2400AC and are the
default for every model. Validation bounds are model-specific: applying
2400AC bounds to a smaller unit could forward out-of-spec values to the
hardware, while larger models would have valid values rejected. Pass
model=MODEL_SOLARFLOW_2400AC ("solarflow-2400ac", matched case-insensitively against the registry
of verified entries; the productKey the device reports is used when
model is not given) or explicit limits=SolarFlowLimits(...) to the
constructor. At validation time the client resolves bounds in this order:
explicit limits, the registry entry for the model, then the 2400AC
default with a one-time warning. Only bounds verified against real
hardware are registered; unverified models always fall back to the
default with that warning.
Connection loss
The client detects a failed session through the next failing BLE write (the keepalive writes every 30 seconds by default, so an abrupt disconnect is detected at most one keepalive interval later) and through client-side protocol errors such as invalid JSON payloads or device ID mismatches. When a session fails:
- pending calls raise
SolarFlowConnectionErrorimmediately instead of waiting for the response timeout client.statusflips toConnectionStatus.DISCONNECTED- an optional
connection_lost_callbackreceives the underlying exception
Device-reported errors (method == "error") do not fail the session.
client.last_error holds a SolarFlowDeviceError for the most recent one,
if any, and connect() clears it.
Messages that are still being processed when the session fails keep flowing
to update_callback, with update.status reflecting DISCONNECTED. Call
connect() again to start a fresh session.
Probe
The developer-only probe captures raw GATT traffic through an ESPHome Bluetooth proxy. It never sends device-setting writes.
List advertisements:
uv run python -m scripts.probe_solarflow \
--proxy "the-ip-of-your-esphome-bluetooth-proxy" \
--noise-psk "the-encryption-key-of-your-esphome-bluetooth-proxy" \
--list-advertisements \
--show-identities
Capture a target by address or SolarFlow advertisement identifier:
uv run python -m scripts.probe_solarflow \
--proxy "the-ip-of-your-esphome-bluetooth-proxy" \
--noise-psk "the-encryption-key-of-your-esphome-bluetooth-proxy" \
--identifier "DEVICE_IDENTIFIER" \
--capture-seconds 30 \
--output /tmp/solarflow.jsonl
Use --no-handshake for passive notification capture. --scan-seconds
(default 30) and --capture-seconds (default 15) set the scan and capture
windows. Addresses, identifiers,
device IDs, product keys, pack serials, and credentials are redacted from
capture files. --show-identities affects logs only. --proxy,
--noise-psk, --address, and --identifier fall back to the
SOLARFLOW_PROXY, SOLARFLOW_NOISE_PSK, SOLARFLOW_DEVICE_ADDRESS, and
SOLARFLOW_DEVICE_IDENTIFIER environment variables.
The probe uses the same SolarFlowClient protocol flow as the library in
normal mode. Passive mode connects directly to the notification characteristic
without sending protocol writes.
Client diagnostic
scripts/client_diagnostic.py exercises the library directly. It does not
use the probe script. The default run is read-only and requires exactly one of
--address or --identifier.
uv run python -m scripts.client_diagnostic \
--proxy "the-ip-of-your-esphome-bluetooth-proxy" \
--noise-psk "the-encryption-key-of-your-esphome-bluetooth-proxy" \
--identifier "DEVICE_IDENTIFIER" \
--duration 30 \
--output /tmp/solarflow-client-diagnostic.jsonl
You can put the connection settings in the ignored file
scripts/client_diagnostic.local.json:
{
"proxy": "the-ip-of-your-esphome-bluetooth-proxy",
"noise_psk": "the-encryption-key-of-your-esphome-bluetooth-proxy",
"identifier": "DEVICE_IDENTIFIER"
}
Then run:
uv run python -m scripts.client_diagnostic --duration 30
The script prints decoded updates to stdout, as full JSON with --verbose,
and always redacts optional JSONL output. --duration defaults to 15
seconds. Controls require --controls, --confirm-controls, and explicit
--min-soc and --soc values. --input-limit, --output-limit, and
--ac-mode are optional. Use --config to read a different JSON file.
Never commit the local config or a real PSK.
Development
See CONTRIBUTING.md for setup, the gate, and the pull request expectations.
uv sync
uv run python -m scripts.check
Run focused tests:
uv run pytest tests/test_client_controls.py
uv run pytest tests/test_client_diagnostic.py
The full gate runs a version drift check, format, Ruff, mypy, branch-covered
tests, coverage, uv build, and uv audit in that order.
License
MIT. See LICENSE.
Metadata
Release files for solarflow-ble 0.3.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| solarflow_ble-0.3.2.tar.gz | 19.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| solarflow_ble-0.3.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 39.4 kB
Release files / solarflow_ble-0.3.2.tar.gz
| Download URL | solarflow_ble-0.3.2.tar.gz |
|---|---|
| Size | 19.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2a2ad8013fafbd2c8e6975d4930ec26794461c4f6df782281705d57e3aced65d
|
|
BLAKE2b-256 checksum How to use checksums |
0979cc5eb10f702709a12e653c387f37ae230bc72b80eb29bd01759af9184d8b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency logRelease files / solarflow_ble-0.3.2-py3-none-any.whl
| Download URL | solarflow_ble-0.3.2-py3-none-any.whl |
|---|---|
| Size | 20.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c2e9881be1f026aaf60c1c38467f2fcf207e6762bad02954f382ffcd9ec1aa50
|
|
BLAKE2b-256 checksum How to use checksums |
f3ce61d32e3b28ec42a8ee19e1cf6e75b9a306777a25a663d670c5601dd0e2b3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency log