Skip to main content

Python: async client for BLUETTI power stations over Modbus

PyPI Version Python Versions License Build Status Open in Dev Containers

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, EP500P and FP are 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 raises BluettiModbusConnectionError (also a modbus_connection.ModbusError).
  • Decoded values land on device.values, a dict[str, Any] keyed by field name. field_names() and get_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 from bluetti_modbus_lib directly.

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).

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 a ModbusUnit built 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 the bluetti-modread CLI 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's max_span = 20, the AC family's one-block-per-field read plans, and FP's settings block read in runs of adjacent registers. For a new profile, run bluetti-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_ADDRESS in base_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.

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/core integration 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.

Open in Dev Containers

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.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for bluetti-modbus 0.28.0
File Size Uploaded
bluetti_modbus-0.28.0.tar.gz 51.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for bluetti-modbus 0.28.0
File Interpreter ABI Platform
bluetti_modbus-0.28.0-py3-none-any.whl Python 3 none any Details

Total release size: 92.2 kB

Release files / bluetti_modbus-0.28.0.tar.gz

Download URL bluetti_modbus-0.28.0.tar.gz
Size 51.1 kB
Tags Source
SHA-256 checksum
How to use checksums
5e660feb9db12ff231810436554214e6b3fe02c295bd5128d9235a64ac939d33
BLAKE2b-256 checksum
How to use checksums
fd7298a019ed0a458d9102d6c8207f9462ec7de10b931dbbc1e4e4833386f3ba
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

Release files / bluetti_modbus-0.28.0-py3-none-any.whl

Download URL bluetti_modbus-0.28.0-py3-none-any.whl
Size 41.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7dce4a2eb0dd3495759a644ba9d887902c342f6163246bff4d2cf4a80f2ac1e0
BLAKE2b-256 checksum
How to use checksums
6f212c91eb419bae47864bd9eb0bb906f0d1a9db0196139e21990d0a37e452aa
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

Release history Release notifications | RSS feed

0.34.0

2 release files

0.33.0

2 release files

0.32.0

2 release files

0.31.0

2 release files

0.30.4

2 release files

0.30.3

2 release files

0.30.2

2 release files

0.30.1

2 release files

0.30.0

2 release files

0.29.0

2 release files

0.28.1

2 release files

This release

0.28.0 This release

2 release files

0.27.2

2 release files

0.27.1

2 release files

0.27.0

2 release files

0.26.0

2 release files

0.25.1

2 release files

0.25.0

2 release files

0.24.0

2 release files

0.23.0

2 release files

0.22.1

2 release files

0.22.0

2 release files

0.21.0

2 release files

0.20.0

2 release files

0.19.5

2 release files

0.19.4

2 release files

0.19.3

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.5

2 release files

0.3.4

2 release files

0.3.3

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page