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: 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
  • EP500P (beta) - the BLUETTI EP500Pro, named here after the type string the device gives at 50200, as AC200L is: a home backup station on which Modbus TCP appeared with IoT firmware 9041.17, absent from BLUETTI's official Modbus register list. Its profile (bluetti-registers#35) is AC500's register set plus the read-only SOC thresholds, read on two real units by @TobiGitHubi and @BOPOHOP: device type, SOC, AC/PV powers, grid frequency and firmware versions match the app, and the AC/DC output switches switch the outputs (writable). Energies and PV fields are carried over unverified; grid charging is a read-only state until its effect has been seen
  • FP (beta, read-only) - the BLUETTI FridgePower, named here after the type string the device gives at 50200: on the Modbus side a Balco-family device, whose real US unit answered the whole Balco 260 profile (bluetti-registers#38, by @MadPB) with values matching the app - energy totals, SOC, thresholds, time to empty to the minute. Balco 260's register set under its own name, with the pack voltage at 0.01 V, signed per-phase grid power, the "(Single)" local fields and a DC output switch register. Nothing writable until a write has been tested

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. Confirmed on a Balco260 with three BC260 packs (2026-09-18, bluetti-community/bluetti-modbus#55): slaves 42 and 43 answered the whole block with each pack's own type string, serial number, voltage, SOC, SOH, cycle count, firmware version and energies. An earlier reading of BLUETTI's description had the packs at slave 2, 3, ..., which read as zeros - wrong addresses, not missing data.

One thing to check before showing a pack's values: a slot can answer its serial number and zeros for everything else (seen on slot 41 of that same unit, and on a Balco260 with no pack attached at all). BLUETTI has confirmed this as a firmware issue and plans a fix, and has said a future firmware will also list the unit ids in use and the serial number behind each - until then pack_is_reporting(values) tells a reporting pack from such a slot (type string present, or a non-zero voltage); while it is False, treat the pack as absent rather than as "0 %, 0 V" - its current in particular would otherwise decode to 3000 A, 0 being 30000 below its reference.

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 - and must stay so: on a real AC500 a read at any unit id other than 1 (2, 41-46, 250 were tried) got no reply and froze the device's Modbus TCP stack until a power cycle (bluetti-registers#13, 2026-09-19), so its B300S packs, if they are reachable at all, are not reachable the Balco 260 way. Never address another unit id on an AC500 or an EP500P.

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:

  • AC200L, AC500, Balco260, Balco500, EP2000, EP500P, FP, 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.

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.

An AC500 is less forgiving still: a single-register read at any Modbus unit id other than 1 got no reply and froze its Modbus TCP stack until a power cycle - toggling Modbus TCP on the device's web page did not recover it (bluetti-registers#13, 2026-09-19). A Balco 260 ignores an unknown unit id and carries on. So nothing in this library, and nothing built on it, may address another unit id on an AC500 or an EP500P (an EP500P given the same requests went silent per connection rather than freezing, and answered nothing at those ids either); unit-1 reads of unserved addresses are answered with a clean "illegal data address" there, as on a Balco 260.

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), and an EP500Pro confirms the same switch at the same 3008 (2026-09-20) - the portable stations share one internal map, the Balco family another. 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.

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.26.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.26.0
File Size Uploaded
bluetti_modbus-0.26.0.tar.gz 59.5 kB Details

Built distribution (wheel)

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

Total release size: 101.8 kB

Release files / bluetti_modbus-0.26.0.tar.gz

Download URL bluetti_modbus-0.26.0.tar.gz
Size 59.5 kB
Tags Source
SHA-256 checksum
How to use checksums
13fd77b0892adf52710272705c876e6dc6a9925be5efda245abe4dd48d83ec87
BLAKE2b-256 checksum
How to use checksums
8a7048b7fc7ece1602e1c315153ecfdc08d540bc872440615c4191fc2bd881e4
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 20, 2026.

Transparency log

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

Download URL bluetti_modbus-0.26.0-py3-none-any.whl
Size 42.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b256ac93d48b36baed469969b7e579655adbc8b816bcd70acccd5f9caff11e4d
BLAKE2b-256 checksum
How to use checksums
bbb5f508044bb00a5d89b11ff09414f8259f2311652a25d31d0081f220cbdf42
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 20, 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

This release

0.26.0 This release

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