Python: async client for BLUETTI power stations over Modbus
Asynchronous Python client for BLUETTI power stations over their local Modbus TCP interface.
About
This package decodes what a BLUETTI device reports over Modbus TCP, using
the register maps documented in bluetti-registers. It is
built on modbus-connection, a backend-neutral async
Modbus toolkit: the caller owns the connection and hands the library a
ModbusUnit, so several device objects can share one connection.
The library is read-only by default. The few fields a device is known to
accept writes for (output switches, SOC thresholds - see the table) support
await device.write(field_name, value), validated against the schema's own
bounds with probatio before anything reaches the device.
Supported devices
| Device | get_device() id |
Status | Writable |
|---|---|---|---|
| Balco 260 | balco260 |
Confirmed by BLUETTI and on real hardware | AC output, grid in/out switches, SOC thresholds |
| Balco 500 | balco500 |
From BLUETTI's register spec, no unit seen yet | - |
| EP2000 | ep2000 |
From BLUETTI's register spec, no unit seen yet | - |
| S Meter | smeter |
Confirmed by BLUETTI and on real hardware | - |
| AC500 | ac500 |
Confirmed on real hardware (evidence) | AC/DC output switches |
| AC200L | ac200l |
Beta - confirmed on a real unit against its BLE readings (evidence) | AC/DC output switches |
| EP500Pro | ep500p |
Beta - two real units (evidence) | AC/DC output switches |
| FridgePower | fp |
Confirmed on two real units, read-only (evidence) | - |
Notes:
- Balco 260 reports up to five BC260 expansion packs - see Multiple battery packs.
- Balco 500 / EP2000 come from BLUETTI's official register spec and have not been read on real hardware, so nothing is writable there yet.
- AC500 / EP500Pro / AC200L share one register layout (the AC500's) with per-device scales. SOC thresholds are read-only on them; energies and PV fields on the AC200L and EP500Pro are carried over unverified. None of them exposes per-pack data over Modbus TCP.
- FridgePower is a Balco-family device on the Modbus side: the full BalcoXX register set, pack voltage at 0.01 V, signed grid power.
AC200L,EP500PandFPare named after the type string the device itself gives at register 50200.
Field names, units and addresses come from
bluetti-registers: every devices/*.py is generated
from it by import.py, and a scheduled workflow keeps them
in sync. Have a device this doesn't support, or a value that looks wrong?
See HARDWARE_TESTING.md - no coding experience
required.
Enabling Modbus TCP on your device
Modbus TCP is off by default - enable it in the device's own web interface first, then point this library at its IP address. The steps vary by model; see the official bluetti-modbus-tcp-slave documentation.
Installation
pip install bluetti-modbus
That pulls in only modbus-connection's backend-neutral interface - enough
to use the device classes against a ModbusUnit you already have. The
bluetti-modread CLI and the examples below need a concrete backend, via
the cli extra (tmodbus, the default):
pip install "bluetti-modbus[cli]"
--backend pymodbus is also available (pip install "bluetti-modbus[cli-pymodbus]").
Usage
The caller owns the connection and hands the library a unit:
import asyncio
from modbus_connection.tmodbus import connect_tcp
from bluetti_modbus_lib import BluettiModbusConnectionError, get_device
async def main() -> None:
connection = await connect_tcp("10.2.1.60", port=502)
try:
unit = connection.for_unit(1)
device = get_device("balco260", unit)
if device is None:
return
try:
await device.async_update_with_retry()
except BluettiModbusConnectionError as err:
print("Could not read the device:", err)
return
print(device.values["b_soc"], "%")
print(device.values["b_v"], "V")
print(device.values["d_inverter_status"])
finally:
await connection.close()
asyncio.run(main())
- There is no self-describing header to detect the model from:
get_device()takes the id from the table above, and the caller has to know which device it is talking to. async_update_with_retry()retries a transient acknowledge/busy response (Modbus codes 5/6), which these devices return now and then on registers that otherwise read fine.async_update()raises on the first failure instead. A communication failure raisesBluettiModbusConnectionError(also amodbus_connection.ModbusError).- Decoded values land on
device.values, adict[str, Any]keyed by field name.field_names()andget_field()expose each field's address, type, scale, unit and whether it is writable - protocol facts only, no UI concepts. - Everything a caller needs (
get_device, the device classes, the exceptions,BluettiModbusClient, the enums, the pack helpers) is importable frombluetti_modbus_libdirectly.
Multiple battery packs (Balco 260)
A Balco 260 takes up to MAX_BATTERY_PACKS (5) BC260 packs. Pack 1's own
data (b_soc, b_v, serial, ...) is part of the main device's fields. The
pack count and every aggregate field (d_num_battery_packs, b_v_total,
b_soc_total, ... - AGGREGATE_SUMMARY_FIELDS) are only served at the
aggregate unit id AGGREGATE_SLAVE_ID (250); at the device's own unit id
the count always reads 0:
from bluetti_modbus_lib import aggregate_pack_summary
summary = aggregate_pack_summary(connection)
await summary.async_update_with_retry()
print(summary.values["d_num_battery_packs"], "packs")
Each expansion pack answers the "Each Pack Base Information" block
(PACK_INFO_FIELDS) at its own unit id, starting at
EXPANSION_PACK_FIRST_SLAVE_ID (41): pack 2 at 41, pack 3 at 42, and so on.
pack_slave_id() does the arithmetic, battery_pack() builds a Balco260
restricted to that block:
from bluetti_modbus_lib import battery_pack, pack_slave_id
pack2 = battery_pack(connection, pack_slave_id(2))
await pack2.async_update_with_retry()
print(pack2.values["b_soc"], "%")
A slot can answer its serial number and zeros for everything else (a
firmware issue BLUETTI has acknowledged). pack_is_reporting(values) tells
a reporting pack from such a slot; while it is False, treat the pack as
absent rather than as "0 %, 0 V".
These helpers are Balco 260 only. On the AC500, EP500Pro and AC200L,
d_num_battery_packs is a fixed maximum, not a count, and registers
51200-51249 are a window onto whichever pack the BLUETTI app has selected -
the selector is not reachable over Modbus TCP, so per-pack data cannot be
read from that family. Never address another unit id on those devices (see
Device behaviours).
Encrypted mode (Modbus/TLS)
A device's web page offers an encrypted Modbus TCP mode: it is Modbus over
TLS with your own certificates - the page takes a CA certificate, a server
certificate and its key, and the client authenticates with a certificate
signed by that CA, on the same port as plain mode. BluettiModbusClient
speaks it with tls=True (verify = the CA file, check_hostname=False,
client_cert/client_key); the device classes take a
modbus_connection.ModbusTlsParams connection the same way. Leave the mode
off unless you have uploaded certificates: with it on, plain connections
are refused.
CLI
The optional CLI reads a device straight from the terminal - for testing, not something another application should build on (see Architecture):
bluetti-modread -c 10.2.1.60 -p 502 -t balco260
Example output from a real Balco 260 (truncated - one line per field):
d_num_inverters: 1
ac_o_p_total: 84 W
pv_i_p_total: 0 W
ac_o_e_total: 64.7 kWh
d_inverter_status: InverterStatus.GridConnectedOperation
g_i_f: 50.0 Hz
b_v: 27.1 V
b_soc: 100 %
b_cycle_count: 8
b_i_e: 23420 Wh
The output ends with the number of Modbus block reads the update took
(15 Modbus block reads) - a quick way to notice a profile whose fields
do not pool into reads as expected.
Most cumulative energies (ac_o_e_total, ...) are in kWh; the battery
charge/discharge energies (b_i_e, b_o_e) are in Wh, as the device
reports them. Field names follow the
bluetti-registers naming convention.
Architecture
Two things in this library talk Modbus, for two audiences:
- The device classes (
bluetti_modbus_lib.devices) are the integration surface: each takes aModbusUnitbuilt from whichever backend and connection the caller manages. This is what an application should build on. BluettiModbusClient(bluetti_modbus_lib.modbus.client) owns its own connection. It exists for thebluetti-modreadCLI and standalone use - building an application on it would open a second, competing connection to the device.
Device behaviours this library works around
All confirmed on real hardware; the details live in the linked issues.
- A read touching an unserved register gets no reply. A Balco-family
device answers a single-register read of an address it does not serve
with "illegal data address", but a multi-register read that touches one
with silence until the timeout. Hence
Balco260'smax_span = 20, the AC family's one-block-per-field read plans, andFP's settings block read in runs of adjacent registers. For a new profile, runbluetti-modread -t <its own id>on the device before building on it: a dump taken with a neighbouring profile proves the registers, not the read plan. - AC500 and EP500Pro: unit id 1 only. A request to any other unit id gets no reply, and on an AC500 it froze the Modbus TCP stack until a power cycle (details). Nothing in this library, and nothing built on it, may address another unit id on that family.
- Writes are confirmed with the device's internal address. A Write
Single Register (0x06) comes back with the right function code and value
but the setting's address in the device's own register space (the one
the BLUETTI app uses - 57016 → 2022 on a Balco 260, 57005 → 3008 on the
portable stations). The write has applied every time;
write()accepts such a confirmation, logs the echoed address at debug when it is the one on file (_INTERNAL_WRITE_ADDRESSinbase_devices/bluetti_device.py) and at warning when it isn't - that warning is the signal to add an entry. Reported to BLUETTI. - The internal register space is not reachable over Modbus TCP. Reads and writes of the app's own addresses are refused; only the documented registers are translated.
Related projects
This library is the Modbus layer for Home Assistant integrations built on top of it:
hassio-bluetti-modbus- a HACS-installable custom integration, vendoring this library directly (see its own README for why).bluetti-home-assistant- the cloud integration; its built-in Modbus path, now deprecated in favour of the one above, depends on this library via PyPI.- home-assistant/core#180602 - an in-review attempt at a
built-in
home-assistant/coreintegration for the Modbus-only path.
Relationship to Patrick762's bluetti-modbus-lib
This repository started as a fork of
Patrick762/bluetti-modbus-lib and has since diverged
significantly (packaging, testing, retry handling, device coverage).
Patrick762 is still actively maintaining his own version independently and
was asked directly whether he'd like to fold this work back into his project
or join bluetti-community - he's not in a position to commit the time to
that right now, which is completely fine.
Since the PyPI name bluetti-modbus-lib is his and still actively used, this
project is published on PyPI under a different name, bluetti-modbus, to
avoid any ambiguity between the two. The GitHub repository itself keeps its
original name.
Changelog & releases
This repository keeps a change log using GitHub's releases functionality. Publishing a release triggers the PyPI publish workflow directly (via Trusted Publishing, no stored token), setting the package version from the release tag.
Contributing
Contributions are welcome. See CONTRIBUTING.md for how to get started.
Setting up a development environment
The easiest way to start is by opening a Codespace here on GitHub, or by
using the Dev Container feature of Visual Studio Code -
either installs Python 3.13, the cli extra, and every dev tool below
automatically, no local setup required.
To set it up manually instead: this project uses a plain venv + pip
workflow - no Poetry, no Node tooling required. You need at least:
- Python 3.13+
python -m venv .venv
source .venv/bin/activate
pip install -e ".[cli]"
As this repository uses pre-commit, changes are linted and
formatted on every commit once you've run pre-commit install (the
Dev Container does this for you automatically). script/run_checks.sh
installs whatever's still missing (ruff, mypy, pytest) and runs all checks
and tests manually, the same way CI does - formatting, ruff, mypy --strict,
and the test suite with 100% coverage required:
script/run_checks.sh
To run just the Python tests:
pytest
script/format_code.sh applies ruff's safe autofixes and formats the tree.
Authors & contributors
The original author of bluetti-modbus-lib is Patrick762.
This fork is maintained by bluetti-community.
For a full list of all authors and contributors, check the contributor's page.
Sponsoring
If you want to support this project, you can sponsor Patrick762 on GitHub, the original author.
Disclaimer
This project is an independent, community-driven effort. It is not affiliated with, endorsed by, or supported by BLUETTI (PowerOak). All product names, trademarks, and registered trademarks are property of their respective owners.
The register map is based on BLUETTI's own published bluetti-modbus-tcp-slave documentation and the bluetti-registers project. This work is done for interoperability purposes.
Use this software at your own risk. This library is provided without any warranty or support by BLUETTI, and the authors are not responsible for any problems it may cause.
License
MIT License
Copyright (c) 2026 Patrick762
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Release files for bluetti-modbus 0.28.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| bluetti_modbus-0.28.1.tar.gz | 51.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| bluetti_modbus-0.28.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 93.6 kB
Release files / bluetti_modbus-0.28.1.tar.gz
| Download URL | bluetti_modbus-0.28.1.tar.gz |
|---|---|
| Size | 51.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
bbb632df3891ddc1b849e6cc2a2c3c574a7da0a1dd07d607e8b6b816f9cb67c6
|
|
BLAKE2b-256 checksum How to use checksums |
0ed29d65e166429c80cb39239a61b77033b9f4b98781ddf5d11bcc528a1d9569
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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 Sep 21, 2026.
Transparency logRelease files / bluetti_modbus-0.28.1-py3-none-any.whl
| Download URL | bluetti_modbus-0.28.1-py3-none-any.whl |
|---|---|
| Size | 41.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
30f5118afecb2ed8ae9262c260981b53ee55f1e8489c8ea7da24d89dda664938
|
|
BLAKE2b-256 checksum How to use checksums |
6b42016401cebc68ee93a91338c8e310d6a7d610256d6e51fc29a1e2f41d7a3f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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 Sep 21, 2026.
Transparency log