Metadata-Version: 2.5
Name: fellow-stagg-ble
Version: 0.1.1
Summary: Python library for the Fellow Stagg EKG+ kettle over Bluetooth LE
Project-URL: Homepage, https://github.com/ADobin/fellow-stagg-ble
Project-URL: Repository, https://github.com/ADobin/fellow-stagg-ble
Project-URL: Issues, https://github.com/ADobin/fellow-stagg-ble/issues
Project-URL: Changelog, https://github.com/ADobin/fellow-stagg-ble/blob/main/CHANGELOG.md
Author: Alex Dobin
License-Expression: MIT
License-File: LICENSE
Keywords: ble,bleak,bluetooth,ekg+,fellow,home-assistant,kettle,stagg
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
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
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: bleak-retry-connector>=3.0
Requires-Dist: bleak>=2.0
Description-Content-Type: text/markdown

# fellow-stagg-ble

[![CI](https://github.com/ADobin/fellow-stagg-ble/actions/workflows/ci.yml/badge.svg)](https://github.com/ADobin/fellow-stagg-ble/actions/workflows/ci.yml)

Async Python library for the [Fellow Stagg EKG+](https://fellowproducts.com/products/stagg-ekg-plus)
electric kettle over Bluetooth LE, built on [bleak](https://github.com/hbldh/bleak) and
[bleak-retry-connector](https://github.com/Bluetooth-Devices/bleak-retry-connector).

It keeps one connection to the kettle open, decodes the state the kettle streams
(power, target and current temperature, unit, hold, auto-off countdown, on/off base)
into immutable `KettleState` snapshots, and sends power and temperature commands.
Reconnecting, scanning and scheduling are left to the caller, so the library fits a
Home Assistant coordinator as well as a script.

## Installation

```sh
pip install fellow-stagg-ble
```

Requires Python 3.12 or newer.

## Usage

```python
import asyncio

from bleak import BleakScanner

from fellow_stagg_ble import FellowStaggKettle, KettleState, TemperatureUnit


def on_state(state: KettleState) -> None:
    print(f"{state.current_temperature} -> {state.target_temperature} °{state.unit}")


def on_disconnect() -> None:
    print("connection lost")


async def main() -> None:
    device = await BleakScanner.find_device_by_address("AA:BB:CC:DD:EE:FF")
    if device is None:
        raise SystemExit("kettle not found; lift it or press a button so it advertises")

    kettle = FellowStaggKettle(device, state_callback=on_state, disconnected_callback=on_disconnect)
    await kettle.connect()  # returns once power, target and current temperature have arrived
    print(kettle.state)

    await kettle.set_target_temperature(195, TemperatureUnit.FAHRENHEIT)
    await kettle.set_power(True)

    unsubscribe = kettle.register_callback(lambda s: print("hold:", s.hold))
    await asyncio.sleep(30)
    unsubscribe()

    await kettle.disconnect()


asyncio.run(main())
```

### API

| Member | Notes |
| --- | --- |
| `FellowStaggKettle(ble_device, *, state_callback=None, disconnected_callback=None)` | Takes a `bleak.backends.device.BLEDevice`. |
| `await connect(*, state_timeout=5.0)` | Connects with `bleak-retry-connector`, subscribes, authenticates and waits for a complete state. Idempotent while connected. `state_timeout=None` returns right after authentication. |
| `await disconnect()` | Closes the connection without invoking `disconnected_callback`. |
| `await set_power(on)` | |
| `await set_target_temperature(value, unit)` | `ValueError` outside 40–100 °C / 104–212 °F, before any I/O. |
| `state` | Latest `KettleState`; kept across reconnects. |
| `connected`, `last_frame_at`, `address`, `name` | `last_frame_at` is `time.monotonic()` of the last notification on the current connection. |
| `set_ble_device(ble_device)` | Device for the next `connect()`; the open connection is untouched. |
| `register_callback(cb)` | Adds a state callback; returns a function that removes it. |
| `is_fellow_stagg(name, service_uuids)` | Advertisement filter: service UUID or `FELLOW` name prefix. |

Callbacks fire only when the state changed. Exceptions raised by callbacks are logged and
swallowed. Loss of an established connection is reported exactly once through
`disconnected_callback`, whether bleak noticed it or a failed write did; a failed write also
raises `FellowStaggCommandError` and drops the connection. Commands never connect on their own.

`KettleState.complete` is true once power, target temperature and unit are known;
`current_temperature` is `None` both before the first reading and while the kettle reports
none (off or lifted), so it is not part of the check. `connect()` itself waits for an actual
current-temperature frame.

Exceptions: `FellowStaggError` is the base of `FellowStaggConnectionError`,
`FellowStaggNotSupportedError` (characteristic missing), `FellowStaggCommandError` and
`FellowStaggTimeoutError`.

## Protocol

| Item | Value |
| --- | --- |
| Service | `00001820-0000-1000-8000-00805f9b34fb` |
| Characteristic | `00002a80-0000-1000-8000-00805f9b34fb`, properties `write-without-response`, `notify` |
| Advertised name | `FELLOW` + 4 hex digits, e.g. `FELLOW46B9` |
| Init sequence | `ef dd 0b 30 31 32 33 34 35 36 37 38 39 30 31 32 33 34 9a 6d`; written after subscribing, the kettle then streams state at about one frame per second per field |
| Frame | `ef dd` + type byte + payload; notification boundaries do not match frame boundaries |
| `0x00` | power `[on]` |
| `0x01` | hold button `[on]` (slider position; pulses at the setpoint) |
| `0x02` | target temperature `[temp, unit]`, unit `1` = °F, else °C |
| `0x03` | current temperature `[temp, unit]`; `temp = 0x20` means no reading |
| `0x04` | auto-off countdown `[lo, hi]`, 16-bit little-endian seconds (3600 with hold, 300 without) |
| `0x05`, `0x07` | constants `ff ff ff ff` and `00 00 00`, ignored |
| `0x06` | hold engaged `[on]` |
| `0x08` | position `[on_base, x, y]`, 3 bytes, `0` = lifted; the ~11-byte `0x08` echo of the init sequence is ignored |
| Command | `ef dd 0a <seq> <type> <value> <(seq + value) & 0xff> <type>`; type `0` power (`0`/`1`), type `1` target temperature in the kettle's unit; `seq` wraps at 255 |
| Limits | 40–100 °C, 104–212 °F |

The pure protocol helpers live in `fellow_stagg_ble.protocol` (`split_frames`, `parse_frame`,
`build_command`) and have no Bluetooth dependency.

## Limitations

- The kettle accepts a single central at a time. While the Fellow app is connected, other
  clients cannot connect.
- The kettle stops advertising roughly three minutes after it was last used. It still accepts
  a connection if the caller has a cached `BLEDevice`, but a fresh scan will not find it
  until it is lifted or a button is pressed.
- A power cycle resets the setpoint to 212 °F.
- A second disconnect about 27 s after the kettle is powered up has been observed; callers
  should expect to reconnect.
- Writes are spaced at least 0.2 s apart.

## Development

```sh
uv sync --all-groups
uv run pytest --cov=fellow_stagg_ble --cov-report=term-missing
uv run ruff check . && uv run ruff format --check .
uv run mypy
uv build
```

Tests run against a fake `BleakClient`; no hardware is needed.

## Releasing

1. Bump `version` in `pyproject.toml` and `__version__` in `src/fellow_stagg_ble/__init__.py`,
   move the `CHANGELOG.md` entry out of *unreleased*, commit.
2. Tag `vX.Y.Z` and push the tag. The publish workflow builds the sdist and wheel, uploads
   them to PyPI through [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) and
   creates a GitHub Release with the files attached.

Trusted Publishing for the `pypi` environment of this repository is pending setup on PyPI.

## Attribution and license

MIT, see [LICENSE](LICENSE). The command frame format and init sequence descend from Levi
McCallum's MIT-licensed [stagg-ekg-plus-ha](https://github.com/levi/stagg-ekg-plus-ha)
integration. The Home Assistant integration that consumes this library lives at
[ADobin/stagg-ekg-plus-ha](https://github.com/ADobin/stagg-ekg-plus-ha).
