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 three real units (evidence) DC output, grid charging switches

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. Its SOC thresholds refuse writes (unlike the Balco 260's); the AC output and grid feed-in switches are untried.
  • 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

Python 3.12 or newer. On a machine still on an older one, uv runs the commands without installing anything system-wide:

uvx --python 3.13 --from "bluetti-modbus[cli]" bluetti-modread -c 10.2.1.60 -t balco260

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

Modbus RTU over a serial line (RS485)

Some devices carry an RS485 port instead of, or alongside, Modbus TCP - an EP2000's EMS is the case this was written for. BluettiModbusClient takes a serial device in place of a host, and the CLI a --serial:

client = BluettiModbusClient(device_type="ep2000", serial_device="/dev/ttyUSB0")
client = BluettiModbusClient(
    device_type="ep2000",
    serial_device="socket://192.168.1.50:8899",  # a serial-to-TCP gateway
    unit_id=1,
)

The device string is a port path (/dev/ttyUSB0, COM3) or any URL pyserial understands; socket://host:port reaches a transparent serial-to-TCP gateway - an ESP32 bridge, a hardware converter - in which case the line settings live in the gateway and the ones here are ignored. baudrate (9600), parity (N), stopbits (1) and bytesize (8) are the defaults for a real port, and unit_id picks the device on a shared bus. Framing is RTU.

Nothing about this is verified against a BLUETTI device yet: no unit is known to answer as a Modbus slave on its RS485 port, and an EMS may well be the master there (see HARDWARE_TESTING.md before wiring anything to a live bus).

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

Over a serial line, --serial replaces --host:

bluetti-modread -s /dev/ttyUSB0 -t ep2000 --baud 9600 --parity N --stopbits 1
bluetti-modread -s socket://192.168.1.50:8899 -t ep2000 -u 1

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.

Writing a setting

bluetti-modwrite writes one setting register - the switches and SOC thresholds a device declares writable, nothing else:

bluetti-modwrite -c 10.2.1.60 -p 502 -t balco260 -f b_soc_high -v 95
bluetti-modwrite -s /dev/ttyUSB0 -t ep2000 -f b_soc_high -v 95   # same over RS485
-c, -p / -s … the same transport options as the reader: TCP, or a serial line
-t TYPE device type
-f FIELD the field to write; an unknown or read-only name lists the writable ones
-v VALUE a number, or an enum field's member name
-y, --yes skip the confirmation prompt
--settle seconds to wait before reading the setting back (default 2)
--allow-ac-output required to write the AC output switch - it powers whatever the outlets feed
-b backend, as for the reader

It reads that one field - one block read, not a whole device refresh - prints old -> new, asks with the connection closed, then reconnects to write and read the value back. A device applies a setting at once but can serve the old value for a moment afterwards, so the read-back waits and looks again before reporting. The flags follow the bluetti-modwrite in Patrick762's own bluetti-modbus-lib, so a user of that one does not have to relearn the command.

Stop anything else polling the device first - a write landing while Home Assistant is mid-poll collides on a Modbus stack that takes one client at a time. script/write_probe.py's own note says how: disable the integration entry itself, not just its devices, and restart Home Assistant.

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

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.30.3
File Size Uploaded
bluetti_modbus-0.30.3.tar.gz 71.8 kB Details

Built distribution (wheel)

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

Total release size: 122.3 kB

Release files / bluetti_modbus-0.30.3.tar.gz

Download URL bluetti_modbus-0.30.3.tar.gz
Size 71.8 kB
Tags Source
SHA-256 checksum
How to use checksums
f24e5d5f1693bf9b3b8a40ffee514b9b86003acc2a57e1e2de6936dc941414f7
BLAKE2b-256 checksum
How to use checksums
2c75dd5e8ac0494a878b3cdeed08c7069217bd3bc0840bf87fbb05f0050fe15e
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 23, 2026.

Transparency log

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

Download URL bluetti_modbus-0.30.3-py3-none-any.whl
Size 50.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7d06dbd1e8a748ed6c4ca31d82a3050e96af09be5f94aef656aeec8eb1c8bdbb
BLAKE2b-256 checksum
How to use checksums
fd907bb0be74845c9603b2f24c2e6d97cf0a978a8c06a4b3dddae6a02c8ad5c1
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 23, 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

This release

0.30.3 This release

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

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