Metadata-Version: 2.4
Name: besen
Version: 0.4.11
Summary: Async Python client for Besen EV chargers over BLE
Author-email: Yoav Mor <moryoav@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/moryoav/py-besen
Project-URL: Repository, https://github.com/moryoav/py-besen
Project-URL: Issues, https://github.com/moryoav/py-besen/issues
Project-URL: Changelog, https://github.com/moryoav/py-besen/blob/main/CHANGELOG.md
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
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.12
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE.md
Requires-Dist: bleak-retry-connector>=3.10.0
Provides-Extra: dev
Requires-Dist: build>=1.2.0; extra == "dev"
Requires-Dist: mypy>=1.16.0; extra == "dev"
Requires-Dist: pytest>=8.0.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
Requires-Dist: ruff==0.16.6; extra == "dev"
Requires-Dist: twine>=6.0.0; extra == "dev"
Dynamic: license-file

# Besen Python library

[![PyPI][pypi-badge]][pypi-url]
[![Python versions][python-badge]][pypi-url]
[![CI][ci-badge]][ci-url]
[![License][license-badge]][license-url]

`besen` is an async Python client for Besen EV chargers over Bluetooth Low Energy.
It provides connection management, PIN authentication, protocol parsing, typed
state models, and charger commands.

Home Assistant users don't need to install it; the
[Besen integration](https://www.home-assistant.io/integrations/besen) does that
automatically.

## Installation

For standalone Python applications:

```bash
pip install besen
```

Python 3.12 or newer is required. The library has been verified with a Besen BS20.
Other Besen chargers advertising as `ACP#...` and using the same BLE protocol may
also work.

## Basic usage

Applications provide the BLE device lookup function. This keeps discovery policy
outside the library, so callers can use `bleak`, Home Assistant Bluetooth helpers,
or another BLE stack integration.

Replace the example address and PIN with your charger's values. This example
observes state updates for one minute; it does not start or stop charging.
Disconnect other apps or bridges from the charger before running it.

```python
import asyncio
import logging

from bleak import BleakScanner
from bleak.backends.device import BLEDevice

from besen import BesenClient, BesenData

ADDRESS = "AA:BB:CC:DD:EE:FF"
PIN = "123456"


async def main() -> None:
    logging.basicConfig(level=logging.INFO)
    logger = logging.getLogger("besen")

    device = await BleakScanner.find_device_by_address(ADDRESS, timeout=10.0)
    if device is None:
        raise RuntimeError("Charger was not found")

    def device_provider() -> BLEDevice | None:
        return device

    client = BesenClient(
        address=ADDRESS,
        pin=PIN,
        ble_device_provider=device_provider,
        logger=logger,
    )

    def handle_update(data: BesenData) -> None:
        print(
            "available=",
            data.available,
            "charging=",
            data.charge.charger_status,
            "power_w=",
            data.charge.power,
            "session_kwh=",
            data.charge.session_energy,
        )

    remove_listener = client.add_listener(handle_update)

    try:
        await client.async_start()
        await asyncio.sleep(60)
    finally:
        remove_listener()
        await client.async_stop()


asyncio.run(main())
```

## Client API

Create one `BesenClient` per charger:

```python
client = BesenClient(
    address="AA:BB:CC:DD:EE:FF",
    pin="123456",
    ble_device_provider=device_provider,
    logger=logger,
    advertised_name="ACP#Garage",
    sync_clock=True,
)
```

The BLE device provider is called before connection attempts and reconnects. It
must return a connectable `bleak.backends.device.BLEDevice` or `None` when no
connectable path is available.

### Lifecycle and state

- `await client.async_start()` connects, subscribes to notifications, and completes
  the charger login flow.
- `await client.async_stop()` cancels background tasks and disconnects.
- `client.add_listener(callback)` registers a synchronous state callback and
  returns a function that removes it.
- `client.state` returns the latest `BesenData` snapshot.
- `client.is_connected` reports whether the underlying BLE connection is open.

### Control methods

These methods can change the charger or operate real charging hardware. Test them
manually with appropriate supervision before using them in an automation.

- `await client.async_start_charging(amps=None, *, start=None, duration_minutes=None)`
- `await client.async_stop_charging()`
- `await client.async_set_charge_amps(amps)`
- `await client.async_refresh_charge_amps()`
- `await client.async_set_lcd_brightness(brightness)`
- `await client.async_set_temperature_unit(unit)`
- `await client.async_set_language(language)`
- `await client.async_set_device_name(name)`
- `await client.async_refresh_config()`

### Scheduled and time-limited charging

`async_start_charging()` starts a session immediately and without a time limit
unless you pass one of these keyword arguments:

- `start`: a timezone-aware `datetime` up to 24 hours ahead. The charger waits
  until then before it starts charging. The firmware calls this a reservation.
- `duration_minutes`: an integer from 1 to 65534. The charger ends the session
  after that many minutes of charging.

```python
from datetime import UTC, datetime, timedelta

await client.async_start_charging(
    amps=16,
    start=datetime.now(UTC) + timedelta(hours=2),
    duration_minutes=90,
)
```

An invalid start time or duration raises `ValueError` before anything is sent.
The charger reports an accepted schedule back as
`BesenData.charge.scheduled_start` and `BesenData.charge.charging_time_limit`.
These fields are populated only while a reservation is pending or a session is
running. They become `None` when the schedule is cancelled, the session ends,
or the charger reports an unknown or faulted state. After connecting, both a
current-state report and an active session report are needed before these fields
are populated, in either order. A disconnect clears them until fresh reports
arrive.

The charger measures the start time against its own clock. The client sets that
clock on every login and at most once an hour afterwards, so keep
`sync_clock=True` when you schedule sessions.

On a Besen BS20 the charger holds one schedule at a time and accepts it with or
without a vehicle connected. It rejects a new start while a schedule is pending
or a session is charging, and `async_stop_charging()` cancels a pending schedule.

### Charging current and finished sessions

A start request carries a charging current, and the charger keeps it as its
configured current afterwards. When you omit `amps`, the client sends the
current the charger reported. If the charger has not reported one, the client
asks for it and raises `CommandFailed` when no answer arrives within 5 seconds.
It never falls back to the charger's maximum. While the value is unknown, the
client also asks again on every charger heartbeat.

After a session has finished, the charger rejects an immediate start until it
receives a stop. A start without a start time therefore sends that stop first
when `BesenData.charge.current_state` is `Completed` or
`Completed Full Charge`, and waits up to 5 seconds for the charger to leave that
state. Scheduled starts are accepted in that state and send no stop.

## State model

State updates are immutable dataclasses. Every listener receives a full `BesenData`
snapshot.

Important fields:

- `BesenData.available`: whether the latest BLE state is usable.
- `BesenData.authenticated`: whether the PIN login flow completed.
- `BesenData.auth_failed`: whether the charger explicitly rejected the PIN. Automatic
  reconnects stop until a new login is started; a Bluetooth outage or incomplete
  login does not set it.
- `BesenData.info`: charger metadata such as serial, model, phases, firmware, and
  board revision.
- `BesenData.config`: configuration values such as charge amps, device name,
  language, temperature unit, LCD brightness, and RSSI.
- `BesenData.charge`: live charging state such as voltage, current, energy,
  temperature, plug state, output state, and charger status.
- `BesenData.last_command`: last parsed command response.
- `BesenData.last_error`: last connection, protocol, or command error string.

Important telemetry fields:

- `BesenData.charge.power`: charger-reported charging power in watts.
- `BesenData.charge.total_energy`: lifetime energy counter in kWh.
- `BesenData.charge.session_energy`: energy delivered during the current or most
  recently completed charging session in kWh.
- `BesenData.charge.session_start`: timezone-aware UTC datetime from the latest
  session report, or `None` when unset. This remains available after a session
  ends.
- `BesenData.charge.scheduled_start`: the requested start time as a timezone-aware
  UTC datetime, or `None` when unset or no reservation/session is active. The
  firmware calls this a reservation.
- `BesenData.charge.session_duration`: elapsed session time in seconds.
- `BesenData.charge.session_current_limit`: current limit recorded for the session
  in amperes, or `None` when unset.
- `BesenData.charge.charging_time_limit`: time after which the charger ends the
  session in minutes, or `None` when unset, unlimited, or no reservation/session
  is active.
- `BesenData.charge.inner_temp_c` and `BesenData.charge.outer_temp`: temperatures
  in Celsius, or `None` when the charger reports an invalid value.
- `BesenData.charge.l1_voltage`, `l2_voltage`, and `l3_voltage`: phase voltages in
  volts. L2 and L3 are populated only when the charger sends three-phase data.
- `BesenData.charge.l1_amperage`, `l2_amperage`, and `l3_amperage`: phase currents
  in amperes. L2 and L3 are populated only when the charger sends three-phase data.

## Exceptions

All library-specific errors inherit from `BesenError`.

- `CannotConnect`: the charger could not be reached or login timed out.
- `NoConnectablePath`: no active BLE path is available.
- `InvalidAuth`: the charger rejected the configured PIN.
- `ProtocolError`: malformed charger data was received.
- `CommandFailed`: a charger command could not be sent, was invalid, or a
  start-charging request was rejected or could not be confirmed.

`async_start_charging()` waits up to 10 seconds for the charger response, including
the Bluetooth write. A response with a charging or reservation error raises
`CommandFailed`, for example when the charger is already charging or already holds
a scheduled start. A successful response confirms the request, while actual
charging state continues to arrive through notifications.

If the response times out, the charging outcome is unknown. The client disconnects
and reconnects without automatically sending another start request. Cancellation
after sending also retires that Bluetooth session so a late reply cannot be
mistaken for the next request's response. Connection cleanup can extend the time
before the method exits. Concurrent start requests wait their turn before the
10-second timeout begins. Other command methods retain their existing behavior.

## Bluetooth notes

Besen chargers normally allow only one active BLE client connection. Stop other
tools or apps that may already be connected to the charger before starting this
client.

The client keeps one active BLE connection open, listens for notifications, replies
to heartbeats, and schedules reconnects when notifications stop. The caller remains
responsible for device discovery, adapter/proxy selection, and deciding when to
start or stop the client.

## Safety and attribution

This library is not a safety controller. Keep hardware protections, wiring, and
current limits appropriate for the installation independently of the software.

The Python library is MIT-licensed, with protocol work based on
[slespersen/evseMQTT](https://github.com/slespersen/evseMQTT). See
[LICENSE][license-url] and
[NOTICE.md](https://github.com/moryoav/py-besen/blob/main/NOTICE.md).

## Development

See [CONTRIBUTING.md](https://github.com/moryoav/py-besen/blob/main/CONTRIBUTING.md)
for the development setup, checks, and release process. Release notes are in
[CHANGELOG.md](https://github.com/moryoav/py-besen/blob/main/CHANGELOG.md).

[pypi-badge]: https://img.shields.io/pypi/v/besen?style=flat-square&label=PyPI
[pypi-url]: https://pypi.org/project/besen/
[python-badge]: https://img.shields.io/pypi/pyversions/besen?style=flat-square
[ci-badge]: https://img.shields.io/github/actions/workflow/status/moryoav/py-besen/ci.yml?branch=main&style=flat-square&label=CI
[ci-url]: https://github.com/moryoav/py-besen/actions/workflows/ci.yml
[license-badge]: https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square
[license-url]: https://github.com/moryoav/py-besen/blob/main/LICENSE
