fellow-stagg-ble 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fellow_stagg_ble-0.1.0/.gitignore +28 -0
- fellow_stagg_ble-0.1.0/CHANGELOG.md +25 -0
- fellow_stagg_ble-0.1.0/LICENSE +22 -0
- fellow_stagg_ble-0.1.0/PKG-INFO +181 -0
- fellow_stagg_ble-0.1.0/README.md +154 -0
- fellow_stagg_ble-0.1.0/pyproject.toml +86 -0
- fellow_stagg_ble-0.1.0/src/fellow_stagg_ble/__init__.py +46 -0
- fellow_stagg_ble-0.1.0/src/fellow_stagg_ble/client.py +341 -0
- fellow_stagg_ble-0.1.0/src/fellow_stagg_ble/discovery.py +14 -0
- fellow_stagg_ble-0.1.0/src/fellow_stagg_ble/exceptions.py +21 -0
- fellow_stagg_ble-0.1.0/src/fellow_stagg_ble/models.py +40 -0
- fellow_stagg_ble-0.1.0/src/fellow_stagg_ble/protocol.py +117 -0
- fellow_stagg_ble-0.1.0/src/fellow_stagg_ble/py.typed +0 -0
- fellow_stagg_ble-0.1.0/tests/__init__.py +0 -0
- fellow_stagg_ble-0.1.0/tests/conftest.py +65 -0
- fellow_stagg_ble-0.1.0/tests/test_api.py +60 -0
- fellow_stagg_ble-0.1.0/tests/test_client.py +627 -0
- fellow_stagg_ble-0.1.0/tests/test_discovery.py +22 -0
- fellow_stagg_ble-0.1.0/tests/test_models.py +45 -0
- fellow_stagg_ble-0.1.0/tests/test_protocol.py +161 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
*.egg
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
.eggs/
|
|
10
|
+
|
|
11
|
+
# Virtual environments
|
|
12
|
+
.venv*/
|
|
13
|
+
venv/
|
|
14
|
+
|
|
15
|
+
# Tooling caches
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.mypy_cache/
|
|
18
|
+
.ruff_cache/
|
|
19
|
+
.coverage
|
|
20
|
+
.coverage.*
|
|
21
|
+
coverage.xml
|
|
22
|
+
htmlcov/
|
|
23
|
+
|
|
24
|
+
# Editors
|
|
25
|
+
.idea/
|
|
26
|
+
.vscode/
|
|
27
|
+
*.swp
|
|
28
|
+
.DS_Store
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## 0.1.0 — 2026-08-25
|
|
8
|
+
|
|
9
|
+
Initial release, extracted from the `kettle_ble` module of the
|
|
10
|
+
[stagg-ekg-plus-ha](https://github.com/ADobin/stagg-ekg-plus-ha) Home Assistant integration.
|
|
11
|
+
|
|
12
|
+
- `FellowStaggKettle`: persistent connection via `bleak-retry-connector`, notification
|
|
13
|
+
subscription before the init sequence, explicit write mode from the characteristic
|
|
14
|
+
properties, incremental frame reassembly across notification boundaries, bounded receive
|
|
15
|
+
buffer, per-connection completeness wait, serialized and debounced writes.
|
|
16
|
+
- `KettleState` frozen dataclass and `TemperatureUnit`; callbacks only on change, unit
|
|
17
|
+
changes invalidate both temperatures so unchanged raw bytes are re-emitted.
|
|
18
|
+
- Connection loss reported exactly once through `disconnected_callback`; requested
|
|
19
|
+
disconnects are silent; failed writes drop the connection and raise
|
|
20
|
+
`FellowStaggCommandError`.
|
|
21
|
+
- Pure protocol module: `split_frames`, `parse_frame`, `build_command`, constants.
|
|
22
|
+
- `is_fellow_stagg()` advertisement filter.
|
|
23
|
+
- Exceptions: `FellowStaggError`, `FellowStaggConnectionError`, `FellowStaggNotSupportedError`,
|
|
24
|
+
`FellowStaggCommandError`, `FellowStaggTimeoutError`.
|
|
25
|
+
- Typed (`py.typed`), Python 3.12+.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alex Dobin
|
|
4
|
+
Copyright (c) 2025 Levi McCallum
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fellow-stagg-ble
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python library for the Fellow Stagg EKG+ kettle over Bluetooth LE
|
|
5
|
+
Project-URL: Homepage, https://github.com/ADobin/fellow-stagg-ble
|
|
6
|
+
Project-URL: Repository, https://github.com/ADobin/fellow-stagg-ble
|
|
7
|
+
Project-URL: Issues, https://github.com/ADobin/fellow-stagg-ble/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/ADobin/fellow-stagg-ble/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Alex Dobin
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ble,bleak,bluetooth,ekg+,fellow,home-assistant,kettle,stagg
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Home Automation
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: bleak-retry-connector>=3.0
|
|
25
|
+
Requires-Dist: bleak>=2.0
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# fellow-stagg-ble
|
|
29
|
+
|
|
30
|
+
[](https://github.com/ADobin/fellow-stagg-ble/actions/workflows/ci.yml)
|
|
31
|
+
|
|
32
|
+
Async Python library for the [Fellow Stagg EKG+](https://fellowproducts.com/products/stagg-ekg-plus)
|
|
33
|
+
electric kettle over Bluetooth LE, built on [bleak](https://github.com/hbldh/bleak) and
|
|
34
|
+
[bleak-retry-connector](https://github.com/Bluetooth-Devices/bleak-retry-connector).
|
|
35
|
+
|
|
36
|
+
It keeps one connection to the kettle open, decodes the state the kettle streams
|
|
37
|
+
(power, target and current temperature, unit, hold, auto-off countdown, on/off base)
|
|
38
|
+
into immutable `KettleState` snapshots, and sends power and temperature commands.
|
|
39
|
+
Reconnecting, scanning and scheduling are left to the caller, so the library fits a
|
|
40
|
+
Home Assistant coordinator as well as a script.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
pip install fellow-stagg-ble
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Requires Python 3.12 or newer.
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import asyncio
|
|
54
|
+
|
|
55
|
+
from bleak import BleakScanner
|
|
56
|
+
|
|
57
|
+
from fellow_stagg_ble import FellowStaggKettle, KettleState, TemperatureUnit
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def on_state(state: KettleState) -> None:
|
|
61
|
+
print(f"{state.current_temperature} -> {state.target_temperature} °{state.unit}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def on_disconnect() -> None:
|
|
65
|
+
print("connection lost")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def main() -> None:
|
|
69
|
+
device = await BleakScanner.find_device_by_address("AA:BB:CC:DD:EE:FF")
|
|
70
|
+
if device is None:
|
|
71
|
+
raise SystemExit("kettle not found; lift it or press a button so it advertises")
|
|
72
|
+
|
|
73
|
+
kettle = FellowStaggKettle(device, state_callback=on_state, disconnected_callback=on_disconnect)
|
|
74
|
+
await kettle.connect() # returns once power, target and current temperature have arrived
|
|
75
|
+
print(kettle.state)
|
|
76
|
+
|
|
77
|
+
await kettle.set_target_temperature(195, TemperatureUnit.FAHRENHEIT)
|
|
78
|
+
await kettle.set_power(True)
|
|
79
|
+
|
|
80
|
+
unsubscribe = kettle.register_callback(lambda s: print("hold:", s.hold))
|
|
81
|
+
await asyncio.sleep(30)
|
|
82
|
+
unsubscribe()
|
|
83
|
+
|
|
84
|
+
await kettle.disconnect()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
asyncio.run(main())
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### API
|
|
91
|
+
|
|
92
|
+
| Member | Notes |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| `FellowStaggKettle(ble_device, *, state_callback=None, disconnected_callback=None)` | Takes a `bleak.backends.device.BLEDevice`. |
|
|
95
|
+
| `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. |
|
|
96
|
+
| `await disconnect()` | Closes the connection without invoking `disconnected_callback`. |
|
|
97
|
+
| `await set_power(on)` | |
|
|
98
|
+
| `await set_target_temperature(value, unit)` | `ValueError` outside 40–100 °C / 104–212 °F, before any I/O. |
|
|
99
|
+
| `state` | Latest `KettleState`; kept across reconnects. |
|
|
100
|
+
| `connected`, `last_frame_at`, `address`, `name` | `last_frame_at` is `time.monotonic()` of the last notification on the current connection. |
|
|
101
|
+
| `set_ble_device(ble_device)` | Device for the next `connect()`; the open connection is untouched. |
|
|
102
|
+
| `register_callback(cb)` | Adds a state callback; returns a function that removes it. |
|
|
103
|
+
| `is_fellow_stagg(name, service_uuids)` | Advertisement filter: service UUID or `FELLOW` name prefix. |
|
|
104
|
+
|
|
105
|
+
Callbacks fire only when the state changed. Exceptions raised by callbacks are logged and
|
|
106
|
+
swallowed. Loss of an established connection is reported exactly once through
|
|
107
|
+
`disconnected_callback`, whether bleak noticed it or a failed write did; a failed write also
|
|
108
|
+
raises `FellowStaggCommandError` and drops the connection. Commands never connect on their own.
|
|
109
|
+
|
|
110
|
+
`KettleState.complete` is true once power, target temperature and unit are known;
|
|
111
|
+
`current_temperature` is `None` both before the first reading and while the kettle reports
|
|
112
|
+
none (off or lifted), so it is not part of the check. `connect()` itself waits for an actual
|
|
113
|
+
current-temperature frame.
|
|
114
|
+
|
|
115
|
+
Exceptions: `FellowStaggError` is the base of `FellowStaggConnectionError`,
|
|
116
|
+
`FellowStaggNotSupportedError` (characteristic missing), `FellowStaggCommandError` and
|
|
117
|
+
`FellowStaggTimeoutError`.
|
|
118
|
+
|
|
119
|
+
## Protocol
|
|
120
|
+
|
|
121
|
+
| Item | Value |
|
|
122
|
+
| --- | --- |
|
|
123
|
+
| Service | `00001820-0000-1000-8000-00805f9b34fb` |
|
|
124
|
+
| Characteristic | `00002a80-0000-1000-8000-00805f9b34fb`, properties `write-without-response`, `notify` |
|
|
125
|
+
| Advertised name | `FELLOW` + 4 hex digits, e.g. `FELLOW46B9` |
|
|
126
|
+
| 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 |
|
|
127
|
+
| Frame | `ef dd` + type byte + payload; notification boundaries do not match frame boundaries |
|
|
128
|
+
| `0x00` | power `[on]` |
|
|
129
|
+
| `0x01` | hold button `[on]` (slider position; pulses at the setpoint) |
|
|
130
|
+
| `0x02` | target temperature `[temp, unit]`, unit `1` = °F, else °C |
|
|
131
|
+
| `0x03` | current temperature `[temp, unit]`; `temp = 0x20` means no reading |
|
|
132
|
+
| `0x04` | auto-off countdown `[lo, hi]`, 16-bit little-endian seconds (3600 with hold, 300 without) |
|
|
133
|
+
| `0x05`, `0x07` | constants `ff ff ff ff` and `00 00 00`, ignored |
|
|
134
|
+
| `0x06` | hold engaged `[on]` |
|
|
135
|
+
| `0x08` | position `[on_base, x, y]`, 3 bytes, `0` = lifted; the ~11-byte `0x08` echo of the init sequence is ignored |
|
|
136
|
+
| 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 |
|
|
137
|
+
| Limits | 40–100 °C, 104–212 °F |
|
|
138
|
+
|
|
139
|
+
The pure protocol helpers live in `fellow_stagg_ble.protocol` (`split_frames`, `parse_frame`,
|
|
140
|
+
`build_command`) and have no Bluetooth dependency.
|
|
141
|
+
|
|
142
|
+
## Limitations
|
|
143
|
+
|
|
144
|
+
- The kettle accepts a single central at a time. While the Fellow app is connected, other
|
|
145
|
+
clients cannot connect.
|
|
146
|
+
- The kettle stops advertising roughly three minutes after it was last used. It still accepts
|
|
147
|
+
a connection if the caller has a cached `BLEDevice`, but a fresh scan will not find it
|
|
148
|
+
until it is lifted or a button is pressed.
|
|
149
|
+
- A power cycle resets the setpoint to 212 °F.
|
|
150
|
+
- A second disconnect about 27 s after the kettle is powered up has been observed; callers
|
|
151
|
+
should expect to reconnect.
|
|
152
|
+
- Writes are spaced at least 0.2 s apart.
|
|
153
|
+
|
|
154
|
+
## Development
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
uv sync --all-groups
|
|
158
|
+
uv run pytest --cov=fellow_stagg_ble --cov-report=term-missing
|
|
159
|
+
uv run ruff check . && uv run ruff format --check .
|
|
160
|
+
uv run mypy
|
|
161
|
+
uv build
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Tests run against a fake `BleakClient`; no hardware is needed.
|
|
165
|
+
|
|
166
|
+
## Releasing
|
|
167
|
+
|
|
168
|
+
1. Bump `version` in `pyproject.toml` and `__version__` in `src/fellow_stagg_ble/__init__.py`,
|
|
169
|
+
move the `CHANGELOG.md` entry out of *unreleased*, commit.
|
|
170
|
+
2. Tag `vX.Y.Z` and push the tag. The publish workflow builds the sdist and wheel, uploads
|
|
171
|
+
them to PyPI through [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) and
|
|
172
|
+
creates a GitHub Release with the files attached.
|
|
173
|
+
|
|
174
|
+
Trusted Publishing for the `pypi` environment of this repository is pending setup on PyPI.
|
|
175
|
+
|
|
176
|
+
## Attribution and license
|
|
177
|
+
|
|
178
|
+
MIT, see [LICENSE](LICENSE). The command frame format and init sequence descend from Levi
|
|
179
|
+
McCallum's MIT-licensed [stagg-ekg-plus-ha](https://github.com/levi/stagg-ekg-plus-ha)
|
|
180
|
+
integration. The Home Assistant integration that consumes this library lives at
|
|
181
|
+
[ADobin/stagg-ekg-plus-ha](https://github.com/ADobin/stagg-ekg-plus-ha).
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# fellow-stagg-ble
|
|
2
|
+
|
|
3
|
+
[](https://github.com/ADobin/fellow-stagg-ble/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Async Python library for the [Fellow Stagg EKG+](https://fellowproducts.com/products/stagg-ekg-plus)
|
|
6
|
+
electric kettle over Bluetooth LE, built on [bleak](https://github.com/hbldh/bleak) and
|
|
7
|
+
[bleak-retry-connector](https://github.com/Bluetooth-Devices/bleak-retry-connector).
|
|
8
|
+
|
|
9
|
+
It keeps one connection to the kettle open, decodes the state the kettle streams
|
|
10
|
+
(power, target and current temperature, unit, hold, auto-off countdown, on/off base)
|
|
11
|
+
into immutable `KettleState` snapshots, and sends power and temperature commands.
|
|
12
|
+
Reconnecting, scanning and scheduling are left to the caller, so the library fits a
|
|
13
|
+
Home Assistant coordinator as well as a script.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pip install fellow-stagg-ble
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires Python 3.12 or newer.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import asyncio
|
|
27
|
+
|
|
28
|
+
from bleak import BleakScanner
|
|
29
|
+
|
|
30
|
+
from fellow_stagg_ble import FellowStaggKettle, KettleState, TemperatureUnit
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def on_state(state: KettleState) -> None:
|
|
34
|
+
print(f"{state.current_temperature} -> {state.target_temperature} °{state.unit}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def on_disconnect() -> None:
|
|
38
|
+
print("connection lost")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def main() -> None:
|
|
42
|
+
device = await BleakScanner.find_device_by_address("AA:BB:CC:DD:EE:FF")
|
|
43
|
+
if device is None:
|
|
44
|
+
raise SystemExit("kettle not found; lift it or press a button so it advertises")
|
|
45
|
+
|
|
46
|
+
kettle = FellowStaggKettle(device, state_callback=on_state, disconnected_callback=on_disconnect)
|
|
47
|
+
await kettle.connect() # returns once power, target and current temperature have arrived
|
|
48
|
+
print(kettle.state)
|
|
49
|
+
|
|
50
|
+
await kettle.set_target_temperature(195, TemperatureUnit.FAHRENHEIT)
|
|
51
|
+
await kettle.set_power(True)
|
|
52
|
+
|
|
53
|
+
unsubscribe = kettle.register_callback(lambda s: print("hold:", s.hold))
|
|
54
|
+
await asyncio.sleep(30)
|
|
55
|
+
unsubscribe()
|
|
56
|
+
|
|
57
|
+
await kettle.disconnect()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
asyncio.run(main())
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### API
|
|
64
|
+
|
|
65
|
+
| Member | Notes |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `FellowStaggKettle(ble_device, *, state_callback=None, disconnected_callback=None)` | Takes a `bleak.backends.device.BLEDevice`. |
|
|
68
|
+
| `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. |
|
|
69
|
+
| `await disconnect()` | Closes the connection without invoking `disconnected_callback`. |
|
|
70
|
+
| `await set_power(on)` | |
|
|
71
|
+
| `await set_target_temperature(value, unit)` | `ValueError` outside 40–100 °C / 104–212 °F, before any I/O. |
|
|
72
|
+
| `state` | Latest `KettleState`; kept across reconnects. |
|
|
73
|
+
| `connected`, `last_frame_at`, `address`, `name` | `last_frame_at` is `time.monotonic()` of the last notification on the current connection. |
|
|
74
|
+
| `set_ble_device(ble_device)` | Device for the next `connect()`; the open connection is untouched. |
|
|
75
|
+
| `register_callback(cb)` | Adds a state callback; returns a function that removes it. |
|
|
76
|
+
| `is_fellow_stagg(name, service_uuids)` | Advertisement filter: service UUID or `FELLOW` name prefix. |
|
|
77
|
+
|
|
78
|
+
Callbacks fire only when the state changed. Exceptions raised by callbacks are logged and
|
|
79
|
+
swallowed. Loss of an established connection is reported exactly once through
|
|
80
|
+
`disconnected_callback`, whether bleak noticed it or a failed write did; a failed write also
|
|
81
|
+
raises `FellowStaggCommandError` and drops the connection. Commands never connect on their own.
|
|
82
|
+
|
|
83
|
+
`KettleState.complete` is true once power, target temperature and unit are known;
|
|
84
|
+
`current_temperature` is `None` both before the first reading and while the kettle reports
|
|
85
|
+
none (off or lifted), so it is not part of the check. `connect()` itself waits for an actual
|
|
86
|
+
current-temperature frame.
|
|
87
|
+
|
|
88
|
+
Exceptions: `FellowStaggError` is the base of `FellowStaggConnectionError`,
|
|
89
|
+
`FellowStaggNotSupportedError` (characteristic missing), `FellowStaggCommandError` and
|
|
90
|
+
`FellowStaggTimeoutError`.
|
|
91
|
+
|
|
92
|
+
## Protocol
|
|
93
|
+
|
|
94
|
+
| Item | Value |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| Service | `00001820-0000-1000-8000-00805f9b34fb` |
|
|
97
|
+
| Characteristic | `00002a80-0000-1000-8000-00805f9b34fb`, properties `write-without-response`, `notify` |
|
|
98
|
+
| Advertised name | `FELLOW` + 4 hex digits, e.g. `FELLOW46B9` |
|
|
99
|
+
| 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 |
|
|
100
|
+
| Frame | `ef dd` + type byte + payload; notification boundaries do not match frame boundaries |
|
|
101
|
+
| `0x00` | power `[on]` |
|
|
102
|
+
| `0x01` | hold button `[on]` (slider position; pulses at the setpoint) |
|
|
103
|
+
| `0x02` | target temperature `[temp, unit]`, unit `1` = °F, else °C |
|
|
104
|
+
| `0x03` | current temperature `[temp, unit]`; `temp = 0x20` means no reading |
|
|
105
|
+
| `0x04` | auto-off countdown `[lo, hi]`, 16-bit little-endian seconds (3600 with hold, 300 without) |
|
|
106
|
+
| `0x05`, `0x07` | constants `ff ff ff ff` and `00 00 00`, ignored |
|
|
107
|
+
| `0x06` | hold engaged `[on]` |
|
|
108
|
+
| `0x08` | position `[on_base, x, y]`, 3 bytes, `0` = lifted; the ~11-byte `0x08` echo of the init sequence is ignored |
|
|
109
|
+
| 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 |
|
|
110
|
+
| Limits | 40–100 °C, 104–212 °F |
|
|
111
|
+
|
|
112
|
+
The pure protocol helpers live in `fellow_stagg_ble.protocol` (`split_frames`, `parse_frame`,
|
|
113
|
+
`build_command`) and have no Bluetooth dependency.
|
|
114
|
+
|
|
115
|
+
## Limitations
|
|
116
|
+
|
|
117
|
+
- The kettle accepts a single central at a time. While the Fellow app is connected, other
|
|
118
|
+
clients cannot connect.
|
|
119
|
+
- The kettle stops advertising roughly three minutes after it was last used. It still accepts
|
|
120
|
+
a connection if the caller has a cached `BLEDevice`, but a fresh scan will not find it
|
|
121
|
+
until it is lifted or a button is pressed.
|
|
122
|
+
- A power cycle resets the setpoint to 212 °F.
|
|
123
|
+
- A second disconnect about 27 s after the kettle is powered up has been observed; callers
|
|
124
|
+
should expect to reconnect.
|
|
125
|
+
- Writes are spaced at least 0.2 s apart.
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
uv sync --all-groups
|
|
131
|
+
uv run pytest --cov=fellow_stagg_ble --cov-report=term-missing
|
|
132
|
+
uv run ruff check . && uv run ruff format --check .
|
|
133
|
+
uv run mypy
|
|
134
|
+
uv build
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Tests run against a fake `BleakClient`; no hardware is needed.
|
|
138
|
+
|
|
139
|
+
## Releasing
|
|
140
|
+
|
|
141
|
+
1. Bump `version` in `pyproject.toml` and `__version__` in `src/fellow_stagg_ble/__init__.py`,
|
|
142
|
+
move the `CHANGELOG.md` entry out of *unreleased*, commit.
|
|
143
|
+
2. Tag `vX.Y.Z` and push the tag. The publish workflow builds the sdist and wheel, uploads
|
|
144
|
+
them to PyPI through [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) and
|
|
145
|
+
creates a GitHub Release with the files attached.
|
|
146
|
+
|
|
147
|
+
Trusted Publishing for the `pypi` environment of this repository is pending setup on PyPI.
|
|
148
|
+
|
|
149
|
+
## Attribution and license
|
|
150
|
+
|
|
151
|
+
MIT, see [LICENSE](LICENSE). The command frame format and init sequence descend from Levi
|
|
152
|
+
McCallum's MIT-licensed [stagg-ekg-plus-ha](https://github.com/levi/stagg-ekg-plus-ha)
|
|
153
|
+
integration. The Home Assistant integration that consumes this library lives at
|
|
154
|
+
[ADobin/stagg-ekg-plus-ha](https://github.com/ADobin/stagg-ekg-plus-ha).
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fellow-stagg-ble"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python library for the Fellow Stagg EKG+ kettle over Bluetooth LE"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Alex Dobin" }]
|
|
13
|
+
requires-python = ">=3.12"
|
|
14
|
+
keywords = ["fellow", "stagg", "ekg+", "kettle", "bluetooth", "ble", "bleak", "home-assistant"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Framework :: AsyncIO",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Home Automation",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"bleak>=2.0",
|
|
29
|
+
"bleak-retry-connector>=3.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/ADobin/fellow-stagg-ble"
|
|
34
|
+
Repository = "https://github.com/ADobin/fellow-stagg-ble"
|
|
35
|
+
Issues = "https://github.com/ADobin/fellow-stagg-ble/issues"
|
|
36
|
+
Changelog = "https://github.com/ADobin/fellow-stagg-ble/blob/main/CHANGELOG.md"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest",
|
|
41
|
+
"pytest-asyncio",
|
|
42
|
+
"pytest-cov",
|
|
43
|
+
"ruff",
|
|
44
|
+
"mypy",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.sdist]
|
|
48
|
+
only-include = ["src/fellow_stagg_ble", "tests", "CHANGELOG.md"]
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["src/fellow_stagg_ble"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
asyncio_mode = "auto"
|
|
55
|
+
testpaths = ["tests"]
|
|
56
|
+
|
|
57
|
+
[tool.coverage.run]
|
|
58
|
+
source = ["fellow_stagg_ble"]
|
|
59
|
+
|
|
60
|
+
[tool.coverage.report]
|
|
61
|
+
fail_under = 95
|
|
62
|
+
show_missing = true
|
|
63
|
+
|
|
64
|
+
[tool.ruff]
|
|
65
|
+
line-length = 100
|
|
66
|
+
target-version = "py312"
|
|
67
|
+
src = ["src", "tests"]
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint]
|
|
70
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC", "RUF"]
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint.isort]
|
|
73
|
+
known-first-party = ["fellow_stagg_ble"]
|
|
74
|
+
|
|
75
|
+
[tool.mypy]
|
|
76
|
+
python_version = "3.12"
|
|
77
|
+
strict = true
|
|
78
|
+
files = ["src/fellow_stagg_ble", "tests"]
|
|
79
|
+
mypy_path = "src"
|
|
80
|
+
|
|
81
|
+
[[tool.mypy.overrides]]
|
|
82
|
+
module = "tests.*"
|
|
83
|
+
disallow_any_generics = false
|
|
84
|
+
disallow_untyped_calls = false
|
|
85
|
+
disallow_untyped_defs = false
|
|
86
|
+
disallow_incomplete_defs = false
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Control and monitor a Fellow Stagg EKG+ kettle over Bluetooth LE."""
|
|
2
|
+
|
|
3
|
+
from .client import DEFAULT_STATE_TIMEOUT, FellowStaggKettle
|
|
4
|
+
from .discovery import is_fellow_stagg
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
FellowStaggCommandError,
|
|
7
|
+
FellowStaggConnectionError,
|
|
8
|
+
FellowStaggError,
|
|
9
|
+
FellowStaggNotSupportedError,
|
|
10
|
+
FellowStaggTimeoutError,
|
|
11
|
+
)
|
|
12
|
+
from .models import KettleState, TemperatureUnit
|
|
13
|
+
from .protocol import (
|
|
14
|
+
CHAR_UUID,
|
|
15
|
+
INIT_SEQUENCE,
|
|
16
|
+
LOCAL_NAME_PREFIX,
|
|
17
|
+
MAX_TEMP_C,
|
|
18
|
+
MAX_TEMP_F,
|
|
19
|
+
MIN_TEMP_C,
|
|
20
|
+
MIN_TEMP_F,
|
|
21
|
+
SERVICE_UUID,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"CHAR_UUID",
|
|
28
|
+
"DEFAULT_STATE_TIMEOUT",
|
|
29
|
+
"INIT_SEQUENCE",
|
|
30
|
+
"LOCAL_NAME_PREFIX",
|
|
31
|
+
"MAX_TEMP_C",
|
|
32
|
+
"MAX_TEMP_F",
|
|
33
|
+
"MIN_TEMP_C",
|
|
34
|
+
"MIN_TEMP_F",
|
|
35
|
+
"SERVICE_UUID",
|
|
36
|
+
"FellowStaggCommandError",
|
|
37
|
+
"FellowStaggConnectionError",
|
|
38
|
+
"FellowStaggError",
|
|
39
|
+
"FellowStaggKettle",
|
|
40
|
+
"FellowStaggNotSupportedError",
|
|
41
|
+
"FellowStaggTimeoutError",
|
|
42
|
+
"KettleState",
|
|
43
|
+
"TemperatureUnit",
|
|
44
|
+
"__version__",
|
|
45
|
+
"is_fellow_stagg",
|
|
46
|
+
]
|