# acaia-lunar-ble

Python library for interacting with the **Acaia Lunar** coffee scale over Bluetooth Low Energy (BLE).

Built for specialty coffee enthusiasts who want to programmatically read weight data from their Acaia Lunar scale. Uses the [bleak](https://github.com/hbldh/bleak) library for cross-platform BLE support (macOS, Linux, Windows).

> **Note:** This library is specifically built for the **Acaia Lunar** model only — it does not support other Acaia scales (Acaia Pearl, Pyxis, etc.). It has been tested with the **Lunar 2021 (Model AL008)**. Models AL009 and AL010 should work as well since they share the same BLE protocol, but have not been tested.


![Demo Image](images/demo.jpg)


[![Demo Video](images/video_thumb.jpg)](https://www.youtube.com/watch?v=LJYfR3pHH9w)

## Features

- Real-time weight streaming (stable and unstable readings)
- Timer readback and control (start, stop, reset)
- Tare command
- Battery level and scale settings (units, auto-off, beep)
- Button event notifications (tare, start, stop, reset pressed on scale)
- Scan and discover nearby Acaia scales
- Automatic heartbeat to maintain connection
- Async/await API built on [bleak](https://github.com/hbldh/bleak)
- Context manager support for clean connect/disconnect

## Installation

```bash
pip install acaia-lunar-ble
```

## Quick Start

```python
import asyncio
from acaia_lunar_ble import ScaleConnection


def on_weight(weight: float):
    print(f"Weight: {weight:.2f} g")


async def main():
    scale = ScaleConnection("YOUR-SCALE-ADDRESS", weight_callback=on_weight)

    if not await scale.connect():
        print("Failed to connect")
        return

    await asyncio.sleep(30)  # read weights for 30 seconds
    await scale.disconnect()


asyncio.run(main())
```

## Finding Your Scale's Address

If you don't know your scale's BLE address, use the built-in discovery:

```python
import asyncio
from acaia_lunar_ble import ScaleConnection


async def main():
    acaia_devices, all_devices = await ScaleConnection.discover()
    for d in acaia_devices:
        print(f"{d.name}: {d.address}")
    if not acaia_devices:
        print("No Acaia scales found. All nearby BLE devices:")
        for d in all_devices:
            print(f"  {d.name or '(unknown)'}: {d.address}")


asyncio.run(main())
```

## Example Script

An interactive example script is included in `examples/demo.py`:

```bash
python examples/demo.py <SCALE-ADDRESS>
```

Interactive commands during the session:

| Key | Action |
|-----|--------|
| `c` | Connect to the scale |
| `d` | Disconnect from the scale |
| `s` | Show connection status |
| `t` | Tare (zero the scale) |
| `1` | Start timer |
| `2` | Stop timer |
| `3` | Reset timer |
| `g` | Get settings (battery, units, auto-off, beep) |
| `r` | Toggle raw BLE data stream logging |
| `h` | Show commands |
| `q` | Quit |

Weight and timer readings update in real-time. Type a command letter and press Enter. Press `q` + Enter to quit, or Ctrl+C.

## Context Manager

For automatic cleanup, use `async with`:

```python
async with ScaleConnection(address, weight_callback=on_weight) as scale:
    await asyncio.sleep(60)  # stream weights for 60 seconds
# automatically disconnects when exiting the block
```

## API Reference

### `ScaleConnection(address, *, ...)`

Main class for interacting with the scale.

**Constructor parameters:**

- **`address`** — BLE address (MAC or UUID) of the scale
- **`weight_callback`** — called with each weight reading in grams (`float`)
- **`timer_callback`** — called with timer value in seconds (`float`)
- **`battery_callback`** — called with battery percentage (`int`, 0-100)
- **`settings_callback`** — called with a `ScaleSettings` object (battery, units, auto-off, beep)
- **`button_callback`** — called with button name (`str`): `"tare"`, `"start"`, `"stop"`, or `"reset"`
- **`notification_callback`** — called with every raw BLE notification payload (`bytearray`)

#### Methods

| Method | Description |
|--------|-------------|
| `await connect()` | Connect and start data streaming. Returns `True` on success. |
| `await disconnect()` | Stop streaming and disconnect. |
| `await scan_until_found()` | Block until the scale is found via BLE scan. |
| `await ScaleConnection.discover(timeout=5.0)` | Scan for nearby Acaia scales. Returns `(acaia_devices, all_devices)`. |
| `await tare()` | Zero the scale. |
| `await start_timer()` | Start the scale's built-in timer. |
| `await stop_timer()` | Stop/pause the timer. |
| `await reset_timer()` | Reset the timer to zero. |
| `await get_settings()` | Request settings (response arrives via `settings_callback`). |

#### Properties

| Property | Type | Description |
|----------|------|-------------|
| `weight` | `float \| None` | Latest weight reading in grams. |
| `timer` | `float \| None` | Latest timer value in seconds. |
| `settings` | `ScaleSettings \| None` | Latest settings from the scale. |
| `is_connected` | `bool` | Whether the scale is currently connected. |

### `ScaleSettings`

Dataclass with fields: `battery` (int), `units` (str), `auto_off_minutes` (int), `beep` (bool).

### `decode_weight(data)`

Standalone function to decode a BLE notification payload into a weight value in grams. Returns `None` if the data doesn't match the expected format.

## Protocol Details

The Acaia Lunar uses a proprietary BLE protocol. Communication happens over two GATT characteristics:

- **Write characteristic** (`49535343-8841-43f4-a8d4-ecbe34729bb3`) — used for sending commands (identify, notification request, heartbeat)
- **Notify characteristic** (`49535343-1e4d-4bd9-ba61-23c647249616`) — streams weight and other data from the scale

The initialization sequence must follow this exact order:

1. Subscribe to notifications
2. Send IDENTIFY handshake
3. Send NOTIFICATION_REQUEST to enable data streaming
4. Start heartbeat loop (~3 second interval)

## Requirements

- Python 3.10+
- Bluetooth Low Energy hardware
- [bleak](https://github.com/hbldh/bleak) (installed automatically)

## License

MIT
