tensite-bms-ble
Read Tensite / UhomeEnergy BMS battery clusters over Bluetooth LE.
Connecting to a cluster's master battery relays frames for every battery in the bank, so one connection covers the whole cluster. No authentication, pairing, or handshake is required.
Works standalone from the command line, and is built to be driven by Home Assistant's shared Bluetooth stack — see Home Assistant compatibility.
Install
pip install tensite-bms-ble
CLI
# List batteries in range
tensite-bms-ble --scan
# Read the whole cluster, stopping as soon as all four have reported
tensite-bms-ble --serial 1417725SLKOPGG08146 --expect 4
# Machine-readable
tensite-bms-ble --serial 1417725SLKOPGG08146 --expect 4 --json
Human-readable output includes bank and per-battery voltage, current, power,
state of charge, temperatures, cell voltages, relay routes and active alarms.
Use --json to retain the same hierarchy in machine-readable form.
Library
from tensite_bms_ble import TensiteClusterClient, async_discover_clusters
found = await async_discover_clusters()
master = found[0]
client = TensiteClusterClient(master.device, serial=master.serial)
reading = await client.async_read(expect=4)
print(reading.total_voltage, reading.current, reading.soc)
for serial, battery in reading.batteries.items():
print(serial, battery.position_label, battery.voltage, battery.soc)
print(" cells", battery.min_cell_mv, battery.max_cell_mv, battery.delta_mv)
ClusterReading → BatteryReading mirrors the hardware: one gateway, several
batteries, sixteen cells each. Cluster-level figures aggregate the bank the way
the wiring implies — voltage and SOC averaged, current and power summed, cell
extremes taken across every cell.
Feeding the parser yourself (from a capture, or from your own transport) is
ReadingAssembler: push notification bytes into feed() and take a
ClusterReading from reading() whenever you want one.
Streaming
async_read connects, listens and disconnects — fine for a one-shot read, but
it pays ~12 s of connection setup for a few seconds of data. The gateway streams
unprompted once notifications are enabled, so a held connection gets everything
the vendor app sees:
from tensite_bms_ble import TensiteClusterStream
stream = TensiteClusterStream(
master.device,
serial=master.serial,
on_update=lambda reading: print(reading.battery_count, reading.min_cell_mv),
)
await stream.async_start() # returns once connected
...
await stream.async_stop() # frees the gateway for other apps
on_update fires as frames arrive — every battery in the bank reports cell
voltages about every 5 s, concurrently — coalesced to at most one call per
update_throttle seconds (default 2). A dropped connection is retried with
backoff until async_stop.
Measured on a 182-second capture of the vendor app: all four batteries emitted cell frames at a median 5.1 s gap, and kept doing so for 81 s after the app's last write. The stream sustains itself; the link-test frame sent every 60 s is precautionary, matching the ~79 s gap between the app's own writes.
Home Assistant compatibility
Bluetooth work inside Home Assistant has rules, and this library follows them so it can be embedded directly. Per the HA Bluetooth docs:
-
It never creates a scanner when you supply one. Home Assistant hands out a shared, adapter-aware scanner; running a second is expensive and breaks when adapter settings change. Pass it in:
from homeassistant.components import bluetooth scanner = bluetooth.async_get_scanner(hass) found = await async_discover_clusters(scanner=scanner)
-
It prefers a resolved
BLEDeviceover an address, so Home Assistant can supply one from its own cache without scanning at all:device = bluetooth.async_ble_device_from_address(hass, address, connectable=True) reading = await TensiteClusterClient(device, serial=serial).async_read(expect=4)
-
Connections go through
bleak_retry_connector.establish_connection, which absorbs the transient first-attempt failures that are normal on BLE. -
A
BleakClientis never reused between connections — a fresh one per read. -
Connection timeouts are clamped to ≥10 s, because BlueZ has to resolve services on a first connection.
Pass connector= to override connection establishment entirely.
Caveats
One central at a time. The ESP32 gateway accepts a single BLE connection. Stop anything else talking to it — another script, a batmon-ha add-on — or connects will fail.
Advertising is intermittent. A battery can be missing from any single scan.
The CLI retries (--scan-attempts); library callers should too.
Read the serial from the advertisement, not BLEDevice.name. On macOS the
latter returns CoreBluetooth's cached GATT Device Name, which is ESP32 for
every unit in the bank. async_discover_clusters handles this.
Every battery reports concurrently, not in rotation. Each unit sends its own
cell frames roughly every 5 s, all of them at once — the bank is not
round-robined, which earlier notes here claimed. A short listening window can
still miss units simply because it is shorter than that cadence. With
async_read, pass expect= to return as soon as the whole bank has reported
instead of waiting out the timeout; with TensiteClusterStream the question does
not arise.
What is decoded
Decoded and verified against the vendor app:
- Pack voltage, current, power, state of charge, and daily charged/discharged energy.
- Four or six pack-temperature sensors, depending on the battery model.
- Sixteen per-cell voltages per battery — an exact match with the app's Cell Voltage tab on live hardware.
- Battery serial, cluster position, topology, and master identification.
- The vendor app's 29 named alarms and their severity levels.
- Four read-only relay-route states.
BatteryReading.voltage is reported by the BMS. cell_sum_voltage
independently sums the sixteen cells, while total_voltage prefers the reported
voltage and falls back to the cell sum if no summary frame has arrived.
Charging state is derived from reported current using a ±0.3 A idle deadband; it is not decoded from the otherwise uninterpreted pack-status byte.
Decoded but not interpreted, or not supported:
- SD-card status is retained as a raw value, but no meaningful nonzero value has been observed and this hardware has no user-serviceable SD-card slot.
- Pack status is retained as a raw byte. Only values
0x00–0x02have been seen and the vendor app does not reveal their meaning. - Relay values
0and3both appear inactive in the vendor app. Only value1is established as active. - The battery model string. No frame carrying one has ever been captured,
so
BatteryReading.modelis alwaysNone.decode_modelsurvives as dead code from a claim that had no provenance behind it — the id it assumed sits inside the app's own temperature range. 0x1051. Four bytes, always zero, about a dozen times a session. The vendor app does not register it for this protocol version either, so it is counted as unhandled rather than guessed at.- Writing settings or relay state. Observed protocol traffic establishes read requests only.
Protocol
Frames are 5E … 7E, checksummed with CRC-16/ARC over the body excluding
the leading 0x5E. Both flag bytes are escaped HDLC-style by the value one
below them: 0x7E travels as 7D 01, 0x5E as 5D 01, and a literal 0x7D
or 0x5D as 7D 02 / 5D 02. Getting the 0x5D half wrong fails quietly
rather than loudly — the frame still looks well-formed, merely shifted by a byte
from the escape onward — and whether a payload contains one depends on the
values being reported, so a message type can vanish at one operating point and
be perfectly fine at another.
Bytes [1:3] are one 16-bit message id, big-endian, whose high byte sorts
messages into classes: 0x10 realtime, 0x20 setting, 0x40 firmware, 0x50
app → device.
Realtime payloads are XOR-masked. The mask is not a captured table but a linear congruential generator lifted from the vendor app, so it runs to any length — which is what makes the 77-byte topology frame readable. The decoded realtime messages are:
0x1000: pack summary telemetry.0x1001: alarm bitfield.0x1002/0x1003: relay and switch routes.0x1005: sixteen cell voltages.0x1021: pack temperatures.0x1032: bank topology and battery count.
Development
uv venv && uv pip install -e ".[dev]"
uv run pytest
Tests run without hardware. The protocol fixtures are real captured bytes checked against vendor-app screenshots taken at the same second, not invented values.
License
AGPL-3.0-or-later, with commercial licences available for use that cannot meet its terms. See LICENSING.md.
Release files for tensite-bms-ble 0.9.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tensite_bms_ble-0.9.0.tar.gz | 98.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tensite_bms_ble-0.9.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 157.6 kB
Release files / tensite_bms_ble-0.9.0.tar.gz
| Download URL | tensite_bms_ble-0.9.0.tar.gz |
|---|---|
| Size | 98.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0d2b424cc051f0c189c9ee94a85dc38564c1599710265f2772a1caa00ea3b08f
|
|
BLAKE2b-256 checksum How to use checksums |
a47de3072d23dd2ae787260edb1b616703778f84415c50db3072418c3ce3102a
|
| 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 16, 2026.
Transparency logRelease files / tensite_bms_ble-0.9.0-py3-none-any.whl
| Download URL | tensite_bms_ble-0.9.0-py3-none-any.whl |
|---|---|
| Size | 59.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9343d89175bdba3f9d8361231b4629e0a984a8df8d487ee2524c1e92d019e424
|
|
BLAKE2b-256 checksum How to use checksums |
93151763a39a65f129532503c3fcb967b1d8379725174889a934dd860b31e30f
|
| 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 16, 2026.
Transparency log