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 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: the exact same register set as Balco 260 - BLUETTI's own official register spec files both under the same generic "BalcoXX" tab, 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

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. BLUETTI confirmed by email that packs 2 and up answer the same way, on their own slave address (2, 3, ...) - battery_pack() builds a Balco260 restricted to just the "Each Pack Base Information" block, at a given pack's slave address:

from bluetti_modbus_lib import battery_pack

pack2 = battery_pack(connection, 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 real hardware beyond b_soc/b_soh: testing on a Balco260 with 3 real, app-confirmed BC260 packs found every other slave address (2 and up) reading a clean, error-free 0 for this block - identical to a second Balco260 with zero packs attached, i.e. not distinguishing a populated pack from an empty one the way aggregate_pack_summary() reliably does. Treat battery_pack() as unconfirmed beyond the two fields BLUETTI explicitly named until that's resolved.

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_t_avg: 0 °C
b_i_e: 23420 Wh

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, and SMeter (bluetti_modbus_lib.devices) are the integration surface: each takes a ModbusUnit supplied 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 the bluetti-modread CLI 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.

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/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.19.1

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.19.1
File Size Uploaded
bluetti_modbus-0.19.1.tar.gz 35.8 kB Details

Built distribution (wheel)

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

Total release size: 68.1 kB

Release files / bluetti_modbus-0.19.1.tar.gz

Download URL bluetti_modbus-0.19.1.tar.gz
Size 35.8 kB
Tags Source
SHA-256 checksum
How to use checksums
d5d8461c7b3fb0839ac27ea1290d45b612913f77af0bc438f5e83bc5b6ffe82e
BLAKE2b-256 checksum
How to use checksums
515a40b785ee8e8b038cea7f702397a932b2400021ad30a5b041b73ea5684ae8
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 8, 2026.

Transparency log

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

Download URL bluetti_modbus-0.19.1-py3-none-any.whl
Size 32.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d801fec99c0784f585b4e26fa81dc50f93afbb4f97a80fc6a5f8553411b16d25
BLAKE2b-256 checksum
How to use checksums
bd7ea68a3c7c0b870bb9df99506679085713226220549705a76efb4ae2a83a0a
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 8, 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

0.28.0

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

This release

0.19.1 This release

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