Metadata-Version: 2.5
Name: tensite-bms-ble
Version: 0.9.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)

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:

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

```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).
