Metadata-Version: 2.5
Name: tensite-bms-ble
Version: 0.8.0
Summary: Read Tensite / UhomeEnergy BMS battery clusters over BLE
License-Expression: AGPL-3.0-or-later
License-File: LICENSE
Keywords: ble,bluetooth,bms,lifepo4,tensite,uhomeenergy
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Home Automation
Requires-Python: >=3.11
Requires-Dist: bleak-retry-connector>=3.5.0
Requires-Dist: bleak>=0.22.0
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# 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](#home-assistant-compatibility).

## Install

```bash
pip install tensite-bms-ble
```

## CLI

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

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

```python
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](https://developers.home-assistant.io/docs/bluetooth/):

- **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:

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

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

```bash
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](LICENSE), with commercial licences available for use
that cannot meet its terms. See [LICENSING.md](LICENSING.md).
