Async Python library for the Fellow Stagg EKG+ electric kettle over Bluetooth LE, built on bleak and 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.
pip install fellow-stagg-bleRequires Python 3.12 or newer.
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())| 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.
| 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; values outside the limits are ignored |
0x03 |
current temperature [temp, unit]; temp = 0x20 (32 °F) or 0 (0 °C) 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.
- 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.
- While the kettle is off its base it intermittently reports temperatures in the other unit (a kettle set to °F sends 91 °C frames in between 196 °F ones). The library ignores unit changes while the kettle is lifted and otherwise accepts one only once two consecutive frames report it, so a unit change made on the base shows up about a second late.
- 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.
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 buildTests run against a fake BleakClient; no hardware is needed.
- Bump
versioninpyproject.tomland__version__insrc/fellow_stagg_ble/__init__.py, move theCHANGELOG.mdentry out of unreleased, commit. - Tag
vX.Y.Zand push the tag. The publish workflow builds the sdist and wheel, uploads them to PyPI through Trusted Publishing and creates a GitHub Release with the files attached.
Trusted Publishing for the pypi environment of this repository is pending setup on PyPI.
MIT, see LICENSE. The command frame format and init sequence descend from Levi McCallum's MIT-licensed stagg-ekg-plus-ha integration. The Home Assistant integration that consumes this library lives at ADobin/stagg-ekg-plus-ha.