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)

for serial, battery in reading.batteries.items():
    print(serial, battery.position_label, battery.min_cell_mv, battery.max_cell_mv)

ClusterReading → BatteryReading mirrors the hardware: one gateway, several batteries, sixteen cells each.

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, model, 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.
  • 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. Realtime payloads are XOR-masked with a generated 32-byte keystream. 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.8.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.8.0
File Size Uploaded
tensite_bms_ble-0.8.0.tar.gz 92.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tensite-bms-ble 0.8.0
File Interpreter ABI Platform
tensite_bms_ble-0.8.0-py3-none-any.whl Python 3 none any Details

Total release size: 148.0 kB

Release files / tensite_bms_ble-0.8.0.tar.gz

Download URL tensite_bms_ble-0.8.0.tar.gz
Size 92.4 kB
Tags Source
SHA-256 checksum
How to use checksums
73267ff0a9e75716d487487b3f5e85087e0734e45349f79a662a56bee8fa0763
BLAKE2b-256 checksum
How to use checksums
894e03e3e844868a9ae0c5441eca2bf0beaf958d3a6e4cef75b3ccafefb508b9
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 Aug 22, 2026.

Transparency log

Release files / tensite_bms_ble-0.8.0-py3-none-any.whl

Download URL tensite_bms_ble-0.8.0-py3-none-any.whl
Size 55.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fd8a159efb134bd4a00e8b4103444c11b9e9c4eb469ecfcad2e08093b6e257c1
BLAKE2b-256 checksum
How to use checksums
c6c69fb520c03893c22b8d4e63da9d4e405010960279851ba1d75d3d488d72fa
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 Aug 22, 2026.

Transparency log

Release history Release notifications | RSS feed

0.9.0

2 release files

This release

0.8.0 This release

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