Skip to main content

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 BLEDevice over 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 BleakClient is 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–0x02 have been seen and the vendor app does not reveal their meaning.
  • Relay values 0 and 3 both appear inactive in the vendor app. Only value 1 is established as active.
  • The battery model string. No frame carrying one has ever been captured, so BatteryReading.model is always None. decode_model survives 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)

Source distribution for tensite-bms-ble 0.9.0
File Size Uploaded
tensite_bms_ble-0.9.0.tar.gz 98.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tensite-bms-ble 0.9.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.9.0 This release

2 release files

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