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 reads Bluetti power stations over Modbus, using the register
maps Bluetti documents for its Modbus TCP slave implementation. It's built on
modbus-connection, a backend-neutral async Modbus
toolkit - the caller owns the connection and hands this library a
ModbusUnit, so a site with several devices shares one connection across
several device objects.
The library is primarily read-only - it decodes what a device reports. A
small, explicit set of fields bluetti-registers' schema marks writeable
(currently Balco 260's 3 control switches and 2 battery SOC thresholds) also
support await device.write(field_name, value), validated against the
schema's own bounds (via probatio) before anything reaches the
device.
Supported out of the box:
- Balco 260: battery voltage/current/SoC/SoH/cycle count, per-string PV, grid import/export, AC output, inverter status/fault/warning, and more
- Balco 500: Balco 260's register set minus 3 of its 4 PV string inputs
- the official datasheet documents a single MPPT tracker, not four - otherwise sourced from the same generic "BalcoXX" tab in BLUETTI's own official register spec, not a Balco260-specific one. Not yet verified against real Balco 500 hardware (no unit exists in this community yet), so every writable field (switches, SoC thresholds) stays read-only here, same policy as EP2000 below
- EP2000: the same Balco 260 register set plus a rated-capacity and EMS/grid-export control block - sourced from BLUETTI's own official register spec, not yet verified against real EP2000 hardware
- AC500: a smaller register set (battery/PV/grid/AC totals, no BC260 expansion pack support yet - see the "Multiple battery packs" section below), confirmed against real hardware by the community (bluetti-official/bluetti-modbus-tcp-slave#5, bluetti-community/bluetti-registers#13) but not yet confirmed by BLUETTI support directly, unlike every other device here
- S Meter: Bluetti's AC meter/CT accessory, confirmed against real hardware
- AC200L / AC200L2 (beta): a portable power station, absent from BLUETTI's official Modbus register list. Its profile (bluetti-registers#31) was derived from AC500's register set and confirmed against a real AC200L2 by cross-checking this library's raw reads against the same unit's simultaneous BLE readings (bluetti-modbus#76, by @awrede): device type, powers, firmware versions, switch states, SOC and SOC thresholds match; grid frequency and total battery voltage need different scales than AC500 at the same addresses. Energies and PV fields are carried over unverified; the DC output switch is confirmed writable, the AC one writable at the owner's request. The device names itself "AC200L" - nothing yet says an original AC200L exposes Modbus TCP at all
Field names, units, and register addresses come from
bluetti-registers - devices/balco260.py is generated
from it by import.py, and a scheduled workflow keeps it in
sync weekly, so main never silently drifts from what it currently
documents. See CONTRIBUTING.md for EP2000's verification
status and this project's writable-field policy.
Have a device model this doesn't support yet, or a value that looks wrong? See HARDWARE_TESTING.md - no coding experience required, including prompts you can hand to an AI assistant.
Enabling Modbus TCP on your device
Modbus TCP is off by default on Bluetti power stations that support it - enable it in the device's own web interface first, then point this library at its IP address. See the official bluetti-modbus-tcp-slave documentation for the exact steps for your model; they vary enough between devices that this README won't guess at them.
Installation
pip install bluetti-modbus
Installing bluetti-modbus alone only pulls in modbus-connection's
backend-neutral interface - enough to use the device classes directly against
a ModbusUnit you already have. The bluetti-modread CLI, and the examples
below, need a concrete backend, installed via the cli extra (currently
tmodbus, the default since 0.4.0 - see
CONTRIBUTING.md for why):
pip install "bluetti-modbus[cli]"
bluetti-modread also accepts --backend pymodbus (pip install "bluetti-modbus[cli-pymodbus]" first) - the previous default, still
available for anyone who needs it.
Usage
The consumer 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, unlike some
Modbus devices - get_device() takes the model as a plain string
("balco260", "ep2000", or "smeter"); the caller has to already know
which one it's talking to. async_update_with_retry() is the entry point
most callers want: it retries once on a transient acknowledge/busy response
(codes 5/6), which Bluetti devices return in practice on registers that
otherwise read fine. Call async_update() directly instead if you want that
first failure to raise immediately. Either way, a communication failure
raises BluettiModbusConnectionError (also a modbus_connection.ModbusError,
for code that already catches that directly) - except for a transient busy
response, which async_update_with_retry decides whether to retry rather
than wrapping. Decoded values land on device.values, a plain
dict[str, Any] keyed by field name; field_names() and get_field()
expose the field metadata (address, type, scale, unit, whether it's
writable) behind each key, deliberately limited to what's true at the
protocol level - no Home Assistant concepts like entity category or device
class live here, since those describe UI presentation, not the register.
Everything above (get_device, the device classes, BluettiModbusError,
BluettiModbusConnectionError, BluettiModbusClient, the inverter enums) is
importable directly from bluetti_modbus_lib, not from the deeper module
paths that define them.
Multiple battery packs (BC260)
Balco 260 only, for now - see the note at the end of this section for AC500.
A Balco 260 can have up to MAX_BATTERY_PACKS (5, confirmed by BLUETTI)
BC260 packs attached. Reading how many are actually there, and every
"total"/aggregate field (d_num_battery_packs, b_v_total, b_c_total,
b_soc_total, b_soh_total, b_status, b_time_to_full_total,
b_time_to_empty_total - registers 51001-51008), needs a second Modbus
unit at the aggregate slave address 250 (0xFA), confirmed by BLUETTI and
by real-hardware testing (reading d_num_battery_packs at the device's own
slave address always returns 0, regardless of how many packs are actually
attached - only slave 250 reports the real count):
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")
AGGREGATE_SUMMARY_FIELDS lists the field names this covers.
Pack 1's own per-pack data (b_soc, b_v, serial number, etc.) is already
part of the main Balco260 device's own fields - reading its own Modbus
slave address covers pack 1. Each BC260 expansion pack answers the same
"Each Pack Base Information" block at its own slave address, and those
addresses start at 41 (EXPANSION_PACK_FIRST_SLAVE_ID, per BLUETTI):
pack 2 is at 41, pack 3 at 42, and so on - pack_slave_id() does that
arithmetic, and battery_pack() builds a Balco260 restricted to just that
block at the given address:
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"], "%")
PACK_INFO_FIELDS lists the field names this covers. Not yet confirmed
against multi-pack hardware: an earlier reading of BLUETTI's description
had the packs at slave 2, 3, ..., and testing on a Balco260 with 3 real,
app-confirmed BC260 packs found those addresses reading a clean, error-free
0 for the whole block - the same as a Balco260 with no pack attached. A
slave-id sweep on a one-pack Balco260 agrees with 41 as far as one pack
can (41 serves the block, as zeros - an empty slot); a Balco260 with two or
more packs read at 41, 42, ... is what settles it
(bluetti-community/bluetti-modbus#55) - one run of
script/probe_unexplored_registers.py --sweep-units (see HARDWARE_TESTING.md,
"Scanning raw registers directly") prints exactly that.
AC500 also has a d_num_battery_packs field, but real-hardware testing
found it means something different there: it stays at a fixed value (the
device's maximum supported packs) regardless of how many are actually
attached, unlike Balco260's confirmed real-time count. aggregate_pack_summary()/
battery_pack() are Balco260-only for now - AC500's own battery-pack
support (it does have swappable packs, e.g. B300S) isn't modeled here yet.
CLI
The optional CLI reads a device straight from the terminal - useful for testing, not something another application should build on (see Architecture below).
bluetti-modread -c 10.2.1.60 -p 502 -t balco260
Example output, captured from a real Balco 260 (truncated - bluetti-modread
prints 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 whole update actually took (e.g.
15 Modbus block reads) - a quick way to notice if a device's fields aren't pooling into
reads as efficiently as expected.
Note the two energy fields above: most cumulative energy fields
(ac_o_e_total, etc.) are reported in kWh, but the battery charge/discharge
ones (b_i_e, b_o_e) are in Wh - both correct as reported by the device,
just worth knowing if you're comparing values across fields. Field names
follow the naming convention documented in
bluetti-registers.
Architecture
Two different things in this library talk Modbus, for two different audiences:
AC500,Balco260,Balco500,EP2000, andSMeter(bluetti_modbus_lib.devices) are the integration surface: each takes aModbusUnitsupplied by the caller, built from whichever backend and connection the caller already manages. This is what an application - a Home Assistant integration, for example - should build on.BluettiModbusClient(bluetti_modbus_lib.modbus.client) is different: it owns and manages its own connection. It exists for thebluetti-modreadCLI above and standalone/manual use, not as something another application should depend on - doing so would open a second, competing connection to the device instead of sharing one.
One device behaviour shapes every read plan here, so it's worth knowing
before changing one: a Balco 260 answers a 1-register read of an address it
doesn't serve with an "illegal data address" exception, but a multi-register
read touching such an address with no reply at all - a timeout, with the
device otherwise alive (confirmed on real hardware, 2026-09-14: 57 of 57
unserved addresses answered the 1-register way, 7 of 7 went silent the
2-register way). That's why Balco260 declares a narrow
max_span, why AC500 reads every field as its own isolated block, and why
probing for an optional block (modbus-connection's read_optional(), or a
scan of your own) only tells you anything if it never spans an address the
device might not serve - see HARDWARE_TESTING.md, section 4.
A second one shapes writes: a Balco 260 confirms a Write Single Register
(function 0x06) with the right function code and value but not the Modbus
address it was asked to write - the same setting's address in the device's
own internal register space, the one the BLUETTI app speaks (57016 → 2022,
57009 → 2207, and so on; confirmed on real hardware for all five of its
writable registers, 2026-09-16). An AC200L2 does the same with its own,
different internal map (57005 → 3008, 2026-09-18). A strict Modbus client
reports that as a protocol error even though the write applied, so
BluettiDevice.write() recognises such a confirmation and treats it as
success, logging the echoed address - at debug when it is the one on file
for that device and register (see _INTERNAL_WRITE_ADDRESS in
base_devices/bluetti_device.py, keyed by device), at warning when it
isn't, which is the signal to add an entry. Reported to BLUETTI. The internal space itself is not served over Modbus TCP: a
1-register read of any of 54 of its addresses is an illegal data address
(confirmed on real hardware, 2026-09-16) - the translation exists for the
documented registers only, so there is nothing to gain by addressing it
directly.
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- a cloud + Modbus hybrid integration, depending 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.22.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.22.1.tar.gz | 51.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| bluetti_modbus-0.22.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 90.1 kB
Release files / bluetti_modbus-0.22.1.tar.gz
| Download URL | bluetti_modbus-0.22.1.tar.gz |
|---|---|
| Size | 51.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
823c2aa9c8cd887766d14358e69b52525ce70f0a25ad1e12a24727ce7af070d3
|
|
BLAKE2b-256 checksum How to use checksums |
42e57e1ec5bd7b1dca773572dd9faf2c3bfe797ea728f855d78ac5f6501520eb
|
| 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 18, 2026.
Transparency logRelease files / bluetti_modbus-0.22.1-py3-none-any.whl
| Download URL | bluetti_modbus-0.22.1-py3-none-any.whl |
|---|---|
| Size | 38.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ef16ec19b4c3fe2b872f6b3b6694f4964786a33547bdbbec38375a05dfefe402
|
|
BLAKE2b-256 checksum How to use checksums |
a43c113a2c64e37d200faf20be84a0cdd14e1314beccef4c00ed9552c324f2ce
|
| 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 18, 2026.
Transparency log