Unofficial Bluetooth LE control for the xBloom Studio coffee machine (loads recipes only — never auto-starts a brew).
Project description
xbloom-ble
🤖 Built & maintained by Claude Code.
Unofficial Bluetooth LE control for the xBloom Studio pour-over coffee machine.
There is no official xBloom API. This package speaks the machine's reverse-engineered Bluetooth Low Energy protocol so you can script and version your recipes — discover the machine, validate a recipe, load it onto the machine, and watch live brew telemetry.
It can also — optionally — sync recipes to your xBloom phone-app account over
the unofficial cloud REST API (xbloom cloud, see below), so a recipe you keep
in version control shows up in the app too.
It is a small, dependency-light Python package (bleak + pyyaml; the cloud
feature adds an optional cryptography dep) with a clean CLI and a fully
documented protocol so others can build on it.
🤖 Designed with agentic use in mind. This was written by an AI coding agent (Claude Code) and tailored to be driven by one — scriptable commands, predictable/parseable output, a fully documented protocol, and a safety model where the tool only ever loads a recipe and a human approves the brew on the machine. (It's just as pleasant to use by hand.)
⚠️ Safety — this tool only loads, it never auto-starts
This is the headline design decision and a hard invariant:
xbloom-bleonly ever LOADS a recipe onto the machine. It sends the load sequence, the machine then prompts you, and YOU physically approve the brew on the machine itself. The tool will never start a brew for you.
The xBloom BLE protocol does have opcodes that force-start a brew (0x42
commit and 0x46 start). This package never builds or sends them. There is
intentionally no code path that emits 0x42/0x46 — build_load_frames()
returns only the four LOAD frames, and there is even a belt-and-braces assertion
that rejects a forbidden opcode if one ever crept in. So the worst this tool can
do is arm a recipe you then have to confirm by hand, with the cup and beans in
front of you.
Tested with
This was developed and verified against an xBloom Studio running firmware
V12.0D.500 — that is the only unit and firmware it has been tested against.
The reverse-engineered protocol may differ on other firmware or hardware revisions,
and could break with future updates.
Reports for other firmware/hardware are very welcome — if it works (or doesn't) on your machine, please open an issue. A BLE capture from a different firmware version is especially useful (see CONTRIBUTING.md; strip any personal data first).
Install
pip install xbloom-ble
From source:
git clone https://github.com/Janczykkkko/xbloom-ble
cd xbloom-ble
pip install -e .
Linux needs BlueZ running (bluetoothd) — it's the standard system
Bluetooth stack and is what bleak talks to. macOS and Windows use their native
BLE stacks. Bluetooth must be on, and on Linux you may need to run as a user
with BLE permissions.
Getting started
Do it in this order:
- Install (above) and make sure Bluetooth is on.
- Find the machine —
xbloom scan— and note its address (or setexport XBLOOM_ADDRESS=…so you never type it again). - Write a recipe — a small YAML file (see Recipe format),
or point at a hosted one by URL. Check it with
xbloom validate <recipe>. - Make sure the phone app is disconnected from the machine before any write — the machine allows a single BLE link, and the app holds it. Close the app and turn the phone's Bluetooth off.
- Pick one of the two paths below.
Then, whichever path you choose:
There are exactly two ways to get a recipe onto the machine — pick one:
▸ Path 1 — Load one recipe and brew it now (
xbloom brew <recipe>). The tool loads the recipe and the machine prompts you to approve the brew on the machine. Best for a one-off brew of whatever you're dialing in.▸ Path 2 — Program the three dial presets, then brew with no phone (
xbloom save-slots <A> <B> <C>). Stores three recipes on the machine's Easy-Mode dial (slots A/B/C) so you can brew straight from the dial, no app, no recipe cards. Best for your everyday go-to coffees.
(Separately, xbloom cloud manages the recipe library in your phone app
account — that's not a way to drive the machine directly; see below.)
Usage
The CLI is xbloom.
Discover your machine
xbloom scan
Found 1 machine(s):
AA:BB:CC:DD:EE:FF XBLOOM-1234
The machine is discovered by its vendor service UUID
(0000e0ff-3c17-d293-8e48-14fe2e4da212) or a device name starting with
XBLOOM — no hardcoded address.
Validate a recipe (no hardware needed)
xbloom validate recipes/example-washed.yaml
OK: 'Example Washed' — 16 g, grind 62, 3 pours, 240 ml total water
Load a recipe and watch the brew
xbloom brew recipes/example-washed.yaml --address AA:BB:CC:DD:EE:FF
or set the address via the environment (so nothing is hardcoded):
export XBLOOM_ADDRESS=AA:BB:CC:DD:EE:FF
xbloom brew recipes/example-washed.yaml
It validates, connects, loads the recipe, then prints:
✋ Recipe loaded. Add beans + cup, then APPROVE ON THE MACHINE to start. (This tool will NOT start it.)
…and streams live status (machine state changes) until the brew completes or the
timeout (--timeout, default 300 s) elapses. A telemetry log is written to
./telemetry-<timestamp>.json.
The recipe argument to brew / validate / cloud can also be an http(s)://
URL — so a recipe can be served and brewed without downloading it first:
xbloom brew https://xbloom.lodywgumce.tv/r/teso-la-leona.yaml
Common flags: --address, --timeout, -v/--verbose, --version.
Program the dial presets (save-slots)
The xBloom Studio's Auto Mode stores three recipes on the machine's dial
(slots A / B / C) so you can brew from the dial with no phone. save-slots
programs all three at once from three recipes — a preset write, it never
brews:
xbloom save-slots light.yaml medium.yaml iced.yaml
xbloom save-slots a.yaml b.yaml c.yaml --scale-off C # disable the scale in slot C's preset
All three are required in one call — the machine only stores the presets once it has received the whole A/B/C set (it saves the batch atomically). Writing a single slot leaves the machine showing RETRY.
⚠️ These presets live on the machine, and the phone app can overwrite them. The xBloom app keeps its own A/B/C assignments and pushes them to the machine over Bluetooth whenever you (re)assign a slot in the app — which will clobber what you set here. There is no way to read the machine's current slots back (the app can't either; it only remembers what it last pushed). So: keep your three recipes somewhere (a folder, a repo) and re-run
save-slotsto restore them, and program the slots when you intend to drive the machine from its dial, not the app.🔌 Before writing, disconnect the phone (close the app and turn its Bluetooth off) — the machine allows one BLE link at a time. The machine can be on any screen:
save-slotsswitches it into Pro mode to write and back to Auto after (see the protocol section), so you don't need to set the mode yourself.
Push recipes to your app account (cloud)
Separately from BLE machine control, xbloom cloud can push recipes to your
xBloom app account via the unofficial xBloom cloud REST API, so a recipe
you define here shows up in the phone app. Needs the optional dependency:
pip install "xbloom-ble[cloud]".
export XBLOOM_EMAIL=you@example.com XBLOOM_PASSWORD=… # or `xbloom cloud login`
xbloom cloud sync my-recipe.yaml # create-or-update a tool-owned recipe (idempotent)
xbloom cloud list # list account recipes ('*' = tool-owned)
xbloom cloud delete <tableId> # only AUTO … recipes can be deleted
xbloom cloud fetch <share-url> # read a publicly shared recipe (no auth)
🔒 Safety: the tool only ever manages recipes it created. Every recipe pushed via
syncis namedAUTO <name>, andsync/deletewill only update or removeAUTO …recipes. Recipes you made by hand in the app are never modified or deleted. This is enforced in code (update_recipe/delete_reciperefuse a non-AUTOtarget) and covered by tests.
This uses a community-reverse-engineered, unofficial API (it may break, and it
touches your real account) — see cloud.py for the
mechanics (RSA-encrypted bodies, endpoints, field schema).
Recipe format
📖 Looking for recipes to start from? Browse the community xBloom recipe ledger — per-bean pour recipes (grind, temps, pour schedule) you can adapt to the format below.
Recipes are plain YAML:
name: Example Washed
dose_g: 16 # coffee dose in grams
grind: 62 # grinder setting (1–80)
ratio: 15 # optional; if given, Σ pour ml must equal dose_g * ratio
stage_temps: [110.0, 90.0] # optional; machine stage temps, default 110/90
pours:
- {ml: 45, temp_c: 93, pattern: spiral, agitation: true, pause_s: 40, rpm: 100, flow_ml_s: 3.0}
- {ml: 100, temp_c: 91, pattern: spiral, pause_s: 10, rpm: 100, flow_ml_s: 3.2}
- {ml: 95, temp_c: 90, pattern: spiral, pause_s: 5, rpm: 100, flow_ml_s: 3.2}
Per-pour fields (ranges are firm — per xBloom Studio specs):
| Field | Meaning |
|---|---|
ml |
Water volume for this pour (≥1 ml). A pour over 127 ml is auto-split by the protocol — not an error. |
temp_c |
Water temperature (40–95 °C, 1 °C steps). |
pattern |
spiral, ring, or center. |
agitation |
true only with spiral (an agitated bloom). Default false. |
pause_s |
Pause after this pour, seconds (0–255; the on-machine countdown caps near 99 s). |
rpm |
Agitation rotation speed (60–120, 10-RPM steps; 0 for center). |
flow_ml_s |
Flow rate in ml/s (3.0–3.5, 0.1 steps). |
The app also exposes two special, non-numeric temperature settings — RT
(room temp) and BP (boiling point) — which are not expressible as a numeric
temp_c; the numeric range is 40–95 °C.
See Recipe limits & valid ranges below for the full table and the firm bounds enforced.
Validation rejects: fewer than two pours (you need at least a bloom and a first
pour), an unknown pattern/agitation combo, out-of-range values, and — if a
ratio is given — a pour total that doesn't equal dose_g * ratio.
Recipe limits & valid ranges
These are the bounds xbloom validate enforces. Most are firm (per the xBloom
Studio published specifications) — a real machine/app limit; a couple of
ceilings (ml, pause_s) remain practical sanity guards. If your machine
behaves differently, please open an issue with a capture — the ranges should
track real hardware.
| Value | Accepted range | Firmness |
|---|---|---|
dose_g |
1–18 g | Firm (per xBloom Studio specs). 18 g is the maximum the xBloom app lets you set. |
grind |
1–80 | Firm (per xBloom Studio specs). The grinder has 80 micro-steps (~18.75 µm each); a lower number is finer. |
temp_c (pour) |
40–95 °C | Firm (per xBloom Studio specs). Settable in 1 °C steps. The app also offers special non-numeric RT (room temp) and BP (boiling point) settings, outside this numeric range. |
stage_temps |
40–130 °C each | Machine preheat/stage set-points (default 110/90 °C) — NOT the pour temperature, so they legitimately exceed the 95 °C pour cap. Wider allowance around the default. |
rpm |
0, or 60–120 | Firm (per xBloom Studio specs). 60–120 in 10-RPM steps; 0 (no agitation) is allowed only for center pours. |
flow_ml_s |
3.0–3.5 ml/s | Firm (per xBloom Studio specs). Settable in 0.1 steps. |
pause_s |
0–255 | The wire byte is 256 − seconds (so 0–255 fits), but the on-machine countdown caps near 99 s — treat 0–99 as the practical range. |
ml (pour) |
1–4000 ml | Lower bound (≥1) is firm; a pour over 127 ml is auto-split by the protocol (not an error). The 4000 ceiling is just a sanity guard. |
pattern |
spiral, ring, center |
Firm. These are the decoded pattern codes; agitation: true is only valid with spiral. |
Source: xBloom Studio published specifications.
The pour count must be ≥2 (at least a bloom and a first pour), and if you give
an optional ratio, Σ(pour ml) must equal dose_g * ratio.
Reverse-engineered protocol
The wire protocol was reverse-engineered from an Android Bluetooth HCI capture and verified by round-tripping against the original recorded frames. This is documented in full so you can build on it.
📓 How it was done: the full capture → parse → differential-decode methodology (reproducible, no special hardware) is written up in docs/REVERSE-ENGINEERING.md.
Frame format
Commands written to ffe1 (host → machine) are:
58 01 01 | CMD(u8) | SEQ(u8) | LEN(u16 LE) | 00 00 | PAYLOAD | CRC16(u16 LE)
58 01 01— constant header.CMD— command opcode.SEQ— sequence byte; the load sequence uses0x1f(31).LEN— total frame length (header through CRC), little-endian, at offset 5.00 00— two constant zero bytes.PAYLOAD— command-specific.CRC16— CRC-16/KERMIT over the whole frame minus the trailing two bytes, stored little-endian.
Notifications on ffe2 (machine → host) use a different shape — see
Status notifications below.
CRC-16/KERMIT: polynomial 0x1021, init 0, reflected input and output, no
final XOR (check value 0x2189 for b"123456789").
GATT
Vendor service 0000e0ff-3c17-d293-8e48-14fe2e4da212:
| Characteristic | Short | Role |
|---|---|---|
| command | ffe1 |
write |
| status | ffe2 |
notify (telemetry) |
| aux | ffe3 |
auxiliary |
⚠️
ffe1accepts only a Write Command (write-without-response, ATT0x52). A Write Request (write-with-response,0x12) is rejected by the firmware with GATT "Unlikely Error" — verified against the vendor app, which never uses a Write Request onffe1. Command acknowledgements come back as notifications onffe2.
The LOAD sequence
Sent frame-by-frame to ffe1, waiting for each ACK on ffe2 (the machine
echoes the command, e.g. 580207a6…):
0xa4— session start. Constant payload01 b9 00 00 00 01 00 00 00.0xa6— dose. Dose in grams as au8at payload offset 9.0xa8— stage temps.01+ f32 LE temp1 + f32 LE temp2 (default110.0,90.0).0x41— pours + grind (see byte map below).
After frame 4 the machine reports STATE 0x1f (armed) and waits for the human
to approve on the machine. The protocol's 0x42 (commit) and 0x46 (start)
opcodes would force-start the brew — this package never sends them.
The 0x41 pours frame payload
01 | LEN(u8 = #body bytes) | <pour segments…> | grind(u8) | tail(u8 = 0xa0)
Each pour becomes an 8-byte segment:
| Offset | Byte | Meaning |
|---|---|---|
| 0 | ml |
Pour volume for this segment, ml. |
| 1 | temp |
Water temperature, °C. |
| 2 | pat |
Pattern code (see table). |
| 3 | agit |
Agitation code (see table). |
| 4 | negpause |
(256 − pause_s) & 0xff — post-pour pause. |
| 5 | 00 |
Constant zero. |
| 6 | rpm |
Agitation rotation speed (0 for center pours). |
| 7 | flow10 |
Flow rate in ml/s × 10 (3.0 → 0x1e). |
Pattern codes — (pattern, agitation) → (pat, agit):
| Pattern | Agitation | pat |
agit |
|---|---|---|---|
| spiral | true | 0x02 | 0x02 |
| spiral | false | 0x02 | 0x00 |
| ring | false | 0x01 | 0x00 |
| center | false | 0x00 | 0x01 |
Large pours: a pour above 127 ml is split into 127-ml 4-byte lead
segments ([ml, temp, pat, agit]) followed by an 8-byte remainder segment
carrying the flow/pause/rpm fields.
Programming the dial presets (Auto-Mode slots)
Auto Mode's three dial presets (A/B/C) are written with a different command,
0x2CF6, and — unlike the LOAD sequence — as a batch of all three, with no
commit frame. Each slot frame:
58 01 02 | f6 2c (=0x2CF6) | LEN(u32 LE) | 01 | SLOT(0/1/2) | FLAGS | <0x41 blob> | CRC16
SLOT—0=A,1=B,2=C.FLAGS—0x12= store with the on-brew scale enabled,0x02= disabled (bit0x10is the scale flag).<0x41 blob>— the samepours | grind | tailbody as the LOAD0x41frame, minus its leading0x01.
The write sequence (reverse-engineered from two app captures + confirmed on hardware):
0xa4session start; wait for the machine to reach idle (0x57state0x01).- Write the three slot frames (A, B, C) back-to-back. The machine acks each
with a
58 02 07 f6 2c … c2 d204notification. - The machine then stores the whole set atomically — signalled by a
0xf8notification and the status progression0x43(saving) →0x25(saved) →0x01(idle). There is no commit frame.
Writing a single slot (or adding a trailing "commit") leaves the machine hung at
0x43 and it shows RETRY — the store only completes with the full A/B/C
batch. Like every other write here, 0x2CF6 is a preset write and never starts
a brew.
Pro vs Auto mode (0x2CF7). The machine only accepts slot writes in Pro
mode (status 0x01, idle). In Auto mode — the on-machine A/B/C recipe
selector — it parks at status 0x41 and rejects the writes (RETRY). The mode is
set with command 0x2CF7: 58 01 02 | f7 2c | LEN | 01 | <4 bytes> | CRC,
where 00 00 00 00 = Pro and 91 32 78 56 = Auto. save_slots therefore sends
Pro before the batch and Auto after (so the fresh presets are ready to
pick on the dial). This too is only a mode switch — it never brews.
The machine exposes no way to read the current slots back — the vendor app doesn't read them either; it just re-pushes whatever it last stored. Keep your recipes and re-run the batch to restore them.
Status notifications (ffe2)
Notifications use their own frame shape (distinct from the command frames above):
58 02 07 | TYPE(u8) | SUB(u8) | LEN(u32 LE) | 0xc1 | PAYLOAD | CRC16(u16 LE)
TYPE(offset 3) is the frame kind:- a command echo / ACK —
TYPEequals the command byte just written (a4/a6/a8/41/…), so an ACK is simply "the notification whose offset-3 byte matches my command" (e.g.5802 07 a6 …acks0xa6). 0x57— a status frame; the byte right after0xc1is the machine state (table below).0x15/0x4b— idle heartbeats (ignored).0x49carries a machine-info dump (serial + firmware string);0x39etc. carry live brew progress (best-effort, not needed for load-only).
- a command echo / ACK —
State byte (inside a 0x57 frame, right after 0xc1):
| State | Name | Meaning |
|---|---|---|
| 0x01 | idle | Idle / ready (also at brew end). |
| 0x1d | loading | Recipe being received. |
| 0x1f | armed | Recipe loaded, awaiting approval. |
| 0x1e | awaiting_confirm | Waiting for the human to confirm. |
| 0x3b | brewing | Brew in progress. |
| 0x41 | complete | Brew complete; also = Auto-mode selector. |
| 0x43 | saving_slots | Auto-Mode slot batch being stored. |
| 0x25 | slots_saved | Auto-Mode slots stored OK (→ idle). |
The load path waits for state 0x1f (armed), which the machine reports right
after it ACKs the 0x41 pours frame — that's when it prompts the human to approve.
Library API
import asyncio
from xbloom_ble import Recipe
from xbloom_ble.client import XBloomClient, scan
async def main():
recipe = Recipe.from_yaml("recipes/example-washed.yaml")
devices = await scan()
async with XBloomClient(devices[0].address) as client:
await client.load_recipe(recipe) # loads only — never starts
# → now physically approve the brew on the machine
await client.stream_telemetry(lambda ev: print(ev), duration=300)
asyncio.run(main())
xbloom_ble.protocol is pure (no BLE) and is the place to start if you want to
build a different front-end:
from xbloom_ble.protocol import build_load_frames
frames = build_load_frames(recipe.to_protocol_dict()) # [a4, a6, a8, 41]
The cloud client (pip install "xbloom-ble[cloud]") pushes to the app account;
sync_recipe is idempotent and only ever manages AUTO … recipes:
from xbloom_ble.cloud import XBloomCloud
client = XBloomCloud(email="…", password="…") # or XBLOOM_EMAIL/XBLOOM_PASSWORD
client.login()
client.sync_recipe(recipe) # create-or-update "AUTO <name>"
Development
pip install -e ".[dev]"
pytest -q
The protocol tests assert this package's frames are byte-for-byte identical
to the reverse-engineering reference, including the 127-ml split, the center and
ring patterns, and an agitated bloom. Point XBLOOM_REFERENCE at the reference
script if it lives elsewhere (those comparison tests skip if it's absent).
Disclaimer
This is an unofficial project. It is not affiliated with, endorsed by, or supported by xBloom in any way. "xBloom" and "xBloom Studio" are trademarks of their respective owner.
The protocol here was reverse-engineered and may be incomplete or wrong; it may break with firmware updates. Use at your own risk — you assume full responsibility for anything you do with your machine. By design this tool only loads recipes and never auto-starts a brew: the machine always prompts you and you approve the brew physically on the device. Even so, supervise your machine. No warranty (see LICENSE).
License
MIT © 2026 Janczykkkko
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file xbloom_ble-1.0.1.tar.gz.
File metadata
- Download URL: xbloom_ble-1.0.1.tar.gz
- Upload date:
- Size: 56.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
00190985e91fcf7a3f1ead367d13a64447b54661d464b7e2c914bfdd39e36b20
|
|
| MD5 |
501d1597d24783f846662ce9f7adf6f3
|
|
| BLAKE2b-256 |
19ab58bec0bd7ad4e8aa066ee8c8a1a1a15be0a63dd4b2849e40e7e882cd9abb
|
Provenance
The following attestation bundles were made for xbloom_ble-1.0.1.tar.gz:
Publisher:
release.yml on Janczykkkko/xbloom-ble
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
xbloom_ble-1.0.1.tar.gz -
Subject digest:
00190985e91fcf7a3f1ead367d13a64447b54661d464b7e2c914bfdd39e36b20 - Sigstore transparency entry: 2038474951
- Sigstore integration time:
-
Permalink:
Janczykkkko/xbloom-ble@0f1e50bce0edda8bc18097eb37961135696940fe -
Branch / Tag:
refs/heads/main - Owner: https://github.com/Janczykkkko
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@0f1e50bce0edda8bc18097eb37961135696940fe -
Trigger Event:
push
-
Statement type:
File details
Details for the file xbloom_ble-1.0.1-py3-none-any.whl.
File metadata
- Download URL: xbloom_ble-1.0.1-py3-none-any.whl
- Upload date:
- Size: 43.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
70648f7107ce6a7bda8a9f9ac7a52ef44831f6537613cf56d7ad6d664fcf4cd6
|
|
| MD5 |
03a15df6c5ce6c664609eaf5252359e3
|
|
| BLAKE2b-256 |
528db1d5de7d2ae01f6e48111176384cb8d33b94c6387b9fb021f14aa468f5dd
|
Provenance
The following attestation bundles were made for xbloom_ble-1.0.1-py3-none-any.whl:
Publisher:
release.yml on Janczykkkko/xbloom-ble
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
xbloom_ble-1.0.1-py3-none-any.whl -
Subject digest:
70648f7107ce6a7bda8a9f9ac7a52ef44831f6537613cf56d7ad6d664fcf4cd6 - Sigstore transparency entry: 2038475206
- Sigstore integration time:
-
Permalink:
Janczykkkko/xbloom-ble@0f1e50bce0edda8bc18097eb37961135696940fe -
Branch / Tag:
refs/heads/main - Owner: https://github.com/Janczykkkko
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@0f1e50bce0edda8bc18097eb37961135696940fe -
Trigger Event:
push
-
Statement type: