Metadata-Version: 2.4
Name: bluetti-modbus
Version: 0.24.0
Summary: Unofficial library for basic communication to Bluetti Power Stations via Modbus
Author: Patrick762, bluetti-community
License: MIT
Project-URL: Homepage, https://github.com/bluetti-community/bluetti-modbus
Keywords: modbus,bluetti,home-assistant
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.13
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: modbus-connection>=4.11.1
Requires-Dist: probatio>=0.11.4
Provides-Extra: cli
Requires-Dist: modbus-connection[tmodbus]>=4.11.1; extra == "cli"
Requires-Dist: tmodbus[async-serial]>=0.6.1; extra == "cli"
Provides-Extra: cli-pymodbus
Requires-Dist: modbus-connection[pymodbus]>=4.11.1; extra == "cli-pymodbus"
Dynamic: license-file

# Python: async client for Bluetti power stations over Modbus

[![PyPI Version][pypi-shield]][pypi]
[![Python Versions][python-versions-shield]][pypi]
[![License][license-shield]](LICENSE)
[![Build Status][build-shield]][build]
[![Open in Dev Containers][devcontainer-shield]][devcontainer]

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`][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`][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
- **EP500Pro** (beta, read-only): 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, read on a real unit by @TobiGitHubi with the AC500 class:
  device type (`EP500P`), SOC, AC/PV powers and firmware versions match
  the app. Energies, PV fields and the SOC thresholds are carried over
  unverified, and nothing is writable until its owner has tested a write

Field names, units, and register addresses come from
[bluetti-registers][bluetti-registers] - `devices/balco260.py` is generated
from it by `import.py`, and a [scheduled workflow][sync-devices] keeps it in
sync weekly, so `main` never silently drifts from what it currently
documents. See [CONTRIBUTING.md](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](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][official-docs] documentation for the exact steps
for your model; they vary enough between devices that this README won't
guess at them.

## Installation

```bash
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][tmodbus], the default since 0.4.0 - see
[CONTRIBUTING.md](CONTRIBUTING.md) for why):

```bash
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:

```python
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):

```python
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:

```python
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 the inverter still
knows can answer its serial number and **zeros for everything else** - a
pack asleep, off, or unplugged since (seen on slot 41 of that same unit, and
on a Balco260 with no active pack at all). `pack_is_reporting(values)` tells
the two apart (type string present, or a non-zero voltage); until it is
True, 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 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](#architecture) below).

```bash
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):

```text
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][bluetti-registers-naming].

## Architecture

Two different things in this library talk Modbus, for two different
audiences:

- `AC200L`, `AC500`, `Balco260`, `Balco500`, `EP2000`, `EP500Pro`, 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.

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`][hassio-bluetti-modbus] - a HACS-installable
  custom integration, vendoring this library directly (see its own README
  for why).
- [`bluetti-home-assistant`][bluetti-home-assistant] - a cloud + Modbus
  hybrid integration, depending on this library via PyPI.
- [home-assistant/core#180602][ha-core-pr] - 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][patrick-original] 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][releases]
functionality. Publishing a release triggers the PyPI publish workflow
directly (via [Trusted Publishing][trusted-publishing], no stored token),
setting the package version from the release tag.

## Contributing

Contributions are welcome. See [CONTRIBUTING.md](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][devcontainer] 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][devcontainer-shield]][devcontainer]

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+

```bash
python -m venv .venv
source .venv/bin/activate
pip install -e ".[cli]"
```

As this repository uses [pre-commit][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:

```bash
script/run_checks.sh
```

To run just the Python tests:

```bash
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][patrick762].
This fork is maintained by [bluetti-community][bluetti-community].

For a full list of all authors and contributors, check
[the contributor's page][contributors].

## Sponsoring

If you want to support this project, you can sponsor
[Patrick762 on GitHub][github-sponsors], 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][official-docs] documentation and the
[bluetti-registers][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.

[bluetti-community]: https://github.com/bluetti-community
[bluetti-home-assistant]: https://github.com/bluetti-community/bluetti-home-assistant
[bluetti-registers-naming]: https://github.com/bluetti-community/bluetti-registers#naming-convention-for-field-names
[bluetti-registers]: https://github.com/bluetti-community/bluetti-registers
[build-shield]: https://github.com/bluetti-community/bluetti-modbus/actions/workflows/tests.yml/badge.svg
[build]: https://github.com/bluetti-community/bluetti-modbus/actions/workflows/tests.yml
[contributors]: https://github.com/bluetti-community/bluetti-modbus/graphs/contributors
[devcontainer-shield]: https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode
[devcontainer]: https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/bluetti-community/bluetti-modbus
[github-sponsors-shield]: https://img.shields.io/badge/sponsor-Patrick762-db61a2.svg?logo=githubsponsors
[github-sponsors]: https://github.com/sponsors/Patrick762
[ha-core-pr]: https://github.com/home-assistant/core/pull/180602
[hassio-bluetti-modbus]: https://github.com/bluetti-community/hassio-bluetti-modbus
[license-shield]: https://img.shields.io/github/license/bluetti-community/bluetti-modbus.svg
[modbus-connection]: https://pypi.org/project/modbus-connection/
[official-docs]: https://github.com/bluetti-official/bluetti-modbus-tcp-slave
[patrick-original]: https://github.com/Patrick762/bluetti-modbus-lib
[patrick762]: https://github.com/Patrick762
[pre-commit]: https://pre-commit.com
[probatio]: https://pypi.org/project/probatio/
[pymodbus]: https://pypi.org/project/pymodbus/
[pypi-shield]: https://img.shields.io/pypi/v/bluetti-modbus.svg
[pypi]: https://pypi.org/project/bluetti-modbus/
[python-versions-shield]: https://img.shields.io/pypi/pyversions/bluetti-modbus.svg
[releases]: https://github.com/bluetti-community/bluetti-modbus/releases
[sync-devices]: .github/workflows/sync-devices.yml
[tmodbus]: https://pypi.org/project/tmodbus/
[trusted-publishing]: https://docs.pypi.org/trusted-publishers/
