xbloom-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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Janczykkkko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,464 @@
1
+ Metadata-Version: 2.4
2
+ Name: xbloom-ble
3
+ Version: 0.1.0
4
+ Summary: Unofficial Bluetooth LE control for the xBloom Studio coffee machine (loads recipes only β€” never auto-starts a brew).
5
+ Author: Janczykkkko
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Janczykkkko/xbloom-ble
8
+ Project-URL: Repository, https://github.com/Janczykkkko/xbloom-ble
9
+ Keywords: xbloom,coffee,bluetooth,ble,bleak,pour-over
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Topic :: Home Automation
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: bleak>=0.21
20
+ Requires-Dist: pyyaml>=6
21
+ Provides-Extra: cloud
22
+ Requires-Dist: cryptography>=41; extra == "cloud"
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7; extra == "dev"
25
+ Requires-Dist: ruff>=0.1; extra == "dev"
26
+ Requires-Dist: cryptography>=41; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # xbloom-ble
30
+
31
+ > πŸ€– Built & maintained by [Claude Code](https://claude.com/claude-code).
32
+
33
+ [![CI](https://github.com/Janczykkkko/xbloom-ble/actions/workflows/ci.yml/badge.svg)](https://github.com/Janczykkkko/xbloom-ble/actions/workflows/ci.yml)
34
+ [![PyPI](https://img.shields.io/pypi/v/xbloom-ble)](https://pypi.org/project/xbloom-ble/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/xbloom-ble)](https://pypi.org/project/xbloom-ble/)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
37
+ ![Built with Claude Code](https://img.shields.io/badge/built%20with-Claude%20Code-d97757)
38
+
39
+ **Unofficial Bluetooth LE control for the [xBloom Studio](https://xbloom.com) pour-over coffee machine.**
40
+
41
+ There is no official xBloom API. This package speaks the machine's
42
+ reverse-engineered Bluetooth Low Energy protocol so you can script and version
43
+ your recipes β€” discover the machine, validate a recipe, **load it onto the
44
+ machine**, and watch live brew telemetry.
45
+
46
+ It can also β€” optionally β€” **sync recipes to your xBloom phone-app account** over
47
+ the unofficial cloud REST API (`xbloom cloud`, see below), so a recipe you keep
48
+ in version control shows up in the app too.
49
+
50
+ It is a small, dependency-light Python package (`bleak` + `pyyaml`; the cloud
51
+ feature adds an optional `cryptography` dep) with a clean CLI and a fully
52
+ documented protocol so others can build on it.
53
+
54
+ > πŸ€– **Designed with agentic use in mind.** This was written *by* an AI coding agent
55
+ > ([Claude Code](https://claude.com/claude-code)) and tailored to be driven *by* one β€”
56
+ > scriptable commands, predictable/parseable output, a fully documented protocol, and a
57
+ > safety model where the tool only ever **loads** a recipe and a **human approves the brew
58
+ > on the machine**. (It's just as pleasant to use by hand.)
59
+
60
+ ---
61
+
62
+ ## ⚠️ Safety β€” this tool only *loads*, it never auto-starts
63
+
64
+ This is the headline design decision and a hard invariant:
65
+
66
+ > **`xbloom-ble` only ever LOADS a recipe onto the machine. It sends the load
67
+ > sequence, the machine then prompts you, and YOU physically approve the brew on
68
+ > the machine itself. The tool will never start a brew for you.**
69
+
70
+ The xBloom BLE protocol *does* have opcodes that force-start a brew (`0x42`
71
+ commit and `0x46` start). **This package never builds or sends them.** There is
72
+ intentionally no code path that emits `0x42`/`0x46` β€” `build_load_frames()`
73
+ returns only the four LOAD frames, and there is even a belt-and-braces assertion
74
+ that rejects a forbidden opcode if one ever crept in. So the worst this tool can
75
+ do is arm a recipe you then have to confirm by hand, with the cup and beans in
76
+ front of you.
77
+
78
+ ---
79
+
80
+ ## Tested with
81
+
82
+ This was developed and verified against an **xBloom Studio running firmware
83
+ `V12.0D.500`** β€” that is the **only unit and firmware it has been tested against**.
84
+ The reverse-engineered protocol may differ on other firmware or hardware revisions,
85
+ and could break with future updates.
86
+
87
+ **Reports for other firmware/hardware are very welcome** β€” if it works (or doesn't)
88
+ on your machine, please open an issue. A BLE capture from a different firmware
89
+ version is especially useful (see [CONTRIBUTING.md](CONTRIBUTING.md); strip any
90
+ personal data first).
91
+
92
+ ---
93
+
94
+ ## Install
95
+
96
+ ```bash
97
+ pip install xbloom-ble
98
+ ```
99
+
100
+ From source:
101
+
102
+ ```bash
103
+ git clone https://github.com/Janczykkkko/xbloom-ble
104
+ cd xbloom-ble
105
+ pip install -e .
106
+ ```
107
+
108
+ **Linux** needs BlueZ running (`bluetoothd`) β€” it's the standard system
109
+ Bluetooth stack and is what `bleak` talks to. macOS and Windows use their native
110
+ BLE stacks. Bluetooth must be on, and on Linux you may need to run as a user
111
+ with BLE permissions.
112
+
113
+ ---
114
+
115
+ ## Usage
116
+
117
+ The CLI is `xbloom`.
118
+
119
+ ### Discover your machine
120
+
121
+ ```bash
122
+ xbloom scan
123
+ ```
124
+
125
+ ```
126
+ Found 1 machine(s):
127
+ AA:BB:CC:DD:EE:FF XBLOOM-1234
128
+ ```
129
+
130
+ The machine is discovered by its vendor **service UUID**
131
+ (`0000e0ff-3c17-d293-8e48-14fe2e4da212`) or a device name starting with
132
+ `XBLOOM` β€” no hardcoded address.
133
+
134
+ ### Validate a recipe (no hardware needed)
135
+
136
+ ```bash
137
+ xbloom validate recipes/example-washed.yaml
138
+ ```
139
+
140
+ ```
141
+ OK: 'Example Washed' β€” 16 g, grind 62, 3 pours, 240 ml total water
142
+ ```
143
+
144
+ ### Load a recipe and watch the brew
145
+
146
+ ```bash
147
+ xbloom brew recipes/example-washed.yaml --address AA:BB:CC:DD:EE:FF
148
+ ```
149
+
150
+ or set the address via the environment (so nothing is hardcoded):
151
+
152
+ ```bash
153
+ export XBLOOM_ADDRESS=AA:BB:CC:DD:EE:FF
154
+ xbloom brew recipes/example-washed.yaml
155
+ ```
156
+
157
+ It validates, connects, **loads** the recipe, then prints:
158
+
159
+ ```
160
+ βœ‹ Recipe loaded. Add beans + cup, then APPROVE ON THE MACHINE to start. (This tool will NOT start it.)
161
+ ```
162
+
163
+ …and streams live status (machine state changes) until the brew completes or the
164
+ timeout (`--timeout`, default 300 s) elapses. A telemetry log is written to
165
+ `./telemetry-<timestamp>.json`.
166
+
167
+ Common flags: `--address`, `--timeout`, `-v/--verbose`, `--version`.
168
+
169
+ ### Push recipes to your app account (cloud)
170
+
171
+ Separately from BLE machine control, `xbloom cloud` can push recipes to your
172
+ xBloom **app account** via the *unofficial* xBloom cloud REST API, so a recipe
173
+ you define here shows up in the phone app. Needs the optional dependency:
174
+ `pip install "xbloom-ble[cloud]"`.
175
+
176
+ ```bash
177
+ export XBLOOM_EMAIL=you@example.com XBLOOM_PASSWORD=… # or `xbloom cloud login`
178
+ xbloom cloud sync my-recipe.yaml # create-or-update a tool-owned recipe (idempotent)
179
+ xbloom cloud list # list account recipes ('*' = tool-owned)
180
+ xbloom cloud delete <tableId> # only AUTO … recipes can be deleted
181
+ xbloom cloud fetch <share-url> # read a publicly shared recipe (no auth)
182
+ ```
183
+
184
+ > πŸ”’ **Safety: the tool only ever manages recipes it created.** Every recipe
185
+ > pushed via `sync` is named **`AUTO <name>`**, and `sync`/`delete` will **only**
186
+ > update or remove `AUTO …` recipes. Recipes you made by hand in the app are
187
+ > never modified or deleted. This is enforced in code (`update_recipe` /
188
+ > `delete_recipe` refuse a non-`AUTO` target) and covered by tests.
189
+
190
+ This uses a community-reverse-engineered, unofficial API (it may break, and it
191
+ touches your real account) β€” see [`cloud.py`](xbloom_ble/cloud.py) for the
192
+ mechanics (RSA-encrypted bodies, endpoints, field schema).
193
+
194
+ ---
195
+
196
+ ## Recipe format
197
+
198
+ > πŸ“– **Looking for recipes to start from?** Browse the community **[xBloom recipe ledger](https://xbloom.lodywgumce.tv)**
199
+ > β€” per-bean pour recipes (grind, temps, pour schedule) you can adapt to the format below.
200
+
201
+ Recipes are plain YAML:
202
+
203
+ ```yaml
204
+ name: Example Washed
205
+ dose_g: 16 # coffee dose in grams
206
+ grind: 62 # grinder setting (1–80)
207
+ ratio: 15 # optional; if given, Ξ£ pour ml must equal dose_g * ratio
208
+ stage_temps: [110.0, 90.0] # optional; machine stage temps, default 110/90
209
+ pours:
210
+ - {ml: 45, temp_c: 93, pattern: spiral, agitation: true, pause_s: 40, rpm: 100, flow_ml_s: 3.0}
211
+ - {ml: 100, temp_c: 91, pattern: spiral, pause_s: 10, rpm: 100, flow_ml_s: 3.2}
212
+ - {ml: 95, temp_c: 90, pattern: spiral, pause_s: 5, rpm: 100, flow_ml_s: 3.2}
213
+ ```
214
+
215
+ Per-pour fields (ranges are **firm β€” per xBloom Studio specs**):
216
+
217
+ | Field | Meaning |
218
+ |-------------|----------------------------------------------------------------|
219
+ | `ml` | Water volume for this pour (β‰₯1 ml). A pour over 127 ml is auto-split by the protocol β€” not an error. |
220
+ | `temp_c` | Water temperature (40–95 Β°C, 1 Β°C steps). |
221
+ | `pattern` | `spiral`, `ring`, or `center`. |
222
+ | `agitation` | `true` only with `spiral` (an agitated bloom). Default `false`. |
223
+ | `pause_s` | Pause after this pour, seconds (0–255; the on-machine countdown caps near 99 s). |
224
+ | `rpm` | Agitation rotation speed (60–120, 10-RPM steps; `0` for `center`). |
225
+ | `flow_ml_s` | Flow rate in ml/s (3.0–3.5, 0.1 steps). |
226
+
227
+ The app also exposes two **special, non-numeric temperature settings β€” `RT`
228
+ (room temp) and `BP` (boiling point)** β€” which are not expressible as a numeric
229
+ `temp_c`; the numeric range is 40–95 Β°C.
230
+
231
+ See **[Recipe limits & valid ranges](#recipe-limits--valid-ranges)** below for the
232
+ full table and the firm bounds enforced.
233
+
234
+ Validation rejects: fewer than two pours (you need at least a bloom and a first
235
+ pour), an unknown `pattern`/`agitation` combo, out-of-range values, and β€” if a
236
+ `ratio` is given β€” a pour total that doesn't equal `dose_g * ratio`.
237
+
238
+ ---
239
+
240
+ ## Recipe limits & valid ranges
241
+
242
+ These are the bounds `xbloom validate` enforces. Most are **firm (per the xBloom
243
+ Studio published specifications)** β€” a real machine/app limit; a couple of
244
+ ceilings (`ml`, `pause_s`) remain practical sanity guards. **If your machine
245
+ behaves differently, please open an issue with a capture β€” the ranges should
246
+ track real hardware.**
247
+
248
+ | Value | Accepted range | Firmness |
249
+ |---------------|----------------|----------|
250
+ | `dose_g` | 1–18 g | **Firm (per xBloom Studio specs).** 18 g is the maximum the xBloom app lets you set. |
251
+ | `grind` | 1–80 | **Firm (per xBloom Studio specs).** The grinder has 80 micro-steps (~18.75 Β΅m each); a *lower* number is *finer*. |
252
+ | `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. |
253
+ | `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. |
254
+ | `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. |
255
+ | `flow_ml_s` | 3.0–3.5 ml/s | **Firm (per xBloom Studio specs).** Settable in 0.1 steps. |
256
+ | `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. |
257
+ | `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. |
258
+ | `pattern` | `spiral`, `ring`, `center` | **Firm.** These are the decoded pattern codes; `agitation: true` is only valid with `spiral`. |
259
+
260
+ > **Source:** xBloom Studio published specifications.
261
+
262
+ The pour count must be **β‰₯2** (at least a bloom and a first pour), and if you give
263
+ an optional `ratio`, Ξ£(pour ml) must equal `dose_g * ratio`.
264
+
265
+ ---
266
+
267
+ ## Reverse-engineered protocol
268
+
269
+ The wire protocol was reverse-engineered from an Android Bluetooth HCI capture
270
+ and verified by round-tripping against the original recorded frames. This is
271
+ documented in full so you can build on it.
272
+
273
+ > πŸ““ **How it was done:** the full capture β†’ parse β†’ differential-decode methodology
274
+ > (reproducible, no special hardware) is written up in
275
+ > **[docs/REVERSE-ENGINEERING.md](docs/REVERSE-ENGINEERING.md)**.
276
+
277
+ ### Frame format
278
+
279
+ **Commands** written to `ffe1` (host β†’ machine) are:
280
+
281
+ ```
282
+ 58 01 01 | CMD(u8) | SEQ(u8) | LEN(u16 LE) | 00 00 | PAYLOAD | CRC16(u16 LE)
283
+ ```
284
+
285
+ - `58 01 01` β€” constant header.
286
+ - `CMD` β€” command opcode.
287
+ - `SEQ` β€” sequence byte; the load sequence uses `0x1f` (31).
288
+ - `LEN` β€” total frame length (header through CRC), little-endian, at offset 5.
289
+ - `00 00` β€” two constant zero bytes.
290
+ - `PAYLOAD` β€” command-specific.
291
+ - `CRC16` β€” **CRC-16/KERMIT** over the whole frame minus the trailing two bytes,
292
+ stored little-endian.
293
+
294
+ **Notifications** on `ffe2` (machine β†’ host) use a **different** shape β€” see
295
+ [Status notifications](#status-notifications-ffe2) below.
296
+
297
+ **CRC-16/KERMIT:** polynomial `0x1021`, init `0`, reflected input and output, no
298
+ final XOR (check value `0x2189` for `b"123456789"`).
299
+
300
+ ### GATT
301
+
302
+ Vendor service `0000e0ff-3c17-d293-8e48-14fe2e4da212`:
303
+
304
+ | Characteristic | Short | Role |
305
+ |----------------|-------|---------------------|
306
+ | command | `ffe1`| write |
307
+ | status | `ffe2`| notify (telemetry) |
308
+ | aux | `ffe3`| auxiliary |
309
+
310
+ > ⚠️ **`ffe1` accepts only a *Write Command* (write-without-response, ATT `0x52`).**
311
+ > A *Write Request* (write-with-response, `0x12`) is rejected by the firmware with
312
+ > GATT "Unlikely Error" β€” verified against the vendor app, which never uses a Write
313
+ > Request on `ffe1`. Command acknowledgements come back as notifications on `ffe2`.
314
+
315
+ ### The LOAD sequence
316
+
317
+ Sent frame-by-frame to `ffe1`, waiting for each ACK on `ffe2` (the machine
318
+ echoes the command, e.g. `580207a6…`):
319
+
320
+ 1. **`0xa4`** β€” session start. Constant payload `01 b9 00 00 00 01 00 00 00`.
321
+ 2. **`0xa6`** β€” dose. Dose in grams as a `u8` at payload offset 9.
322
+ 3. **`0xa8`** β€” stage temps. `01` + f32 LE temp1 + f32 LE temp2 (default
323
+ `110.0`, `90.0`).
324
+ 4. **`0x41`** β€” pours + grind (see byte map below).
325
+
326
+ After frame 4 the machine reports STATE `0x1f` (armed) and **waits for the human
327
+ to approve on the machine**. The protocol's `0x42` (commit) and `0x46` (start)
328
+ opcodes would force-start the brew β€” **this package never sends them.**
329
+
330
+ ### The `0x41` pours frame payload
331
+
332
+ ```
333
+ 01 | LEN(u8 = #body bytes) | <pour segments…> | grind(u8) | tail(u8 = 0xa0)
334
+ ```
335
+
336
+ Each pour becomes an **8-byte segment**:
337
+
338
+ | Offset | Byte | Meaning |
339
+ |--------|------------|------------------------------------------------------|
340
+ | 0 | `ml` | Pour volume for this segment, ml. |
341
+ | 1 | `temp` | Water temperature, Β°C. |
342
+ | 2 | `pat` | Pattern code (see table). |
343
+ | 3 | `agit` | Agitation code (see table). |
344
+ | 4 | `negpause` | `(256 βˆ’ pause_s) & 0xff` β€” post-pour pause. |
345
+ | 5 | `00` | Constant zero. |
346
+ | 6 | `rpm` | Agitation rotation speed (0 for center pours). |
347
+ | 7 | `flow10` | Flow rate in ml/s Γ— 10 (3.0 β†’ `0x1e`). |
348
+
349
+ **Pattern codes** β€” `(pattern, agitation) β†’ (pat, agit)`:
350
+
351
+ | Pattern | Agitation | `pat` | `agit` |
352
+ |----------|-----------|-------|--------|
353
+ | spiral | true | 0x02 | 0x02 |
354
+ | spiral | false | 0x02 | 0x00 |
355
+ | ring | false | 0x01 | 0x00 |
356
+ | center | false | 0x00 | 0x01 |
357
+
358
+ **Large pours:** a pour above 127 ml is split into 127-ml **4-byte lead
359
+ segments** (`[ml, temp, pat, agit]`) followed by an 8-byte remainder segment
360
+ carrying the flow/pause/rpm fields.
361
+
362
+ ### Status notifications (`ffe2`)
363
+
364
+ Notifications use their **own** frame shape (distinct from the command frames
365
+ above):
366
+
367
+ ```
368
+ 58 02 07 | TYPE(u8) | SUB(u8) | LEN(u32 LE) | 0xc1 | PAYLOAD | CRC16(u16 LE)
369
+ ```
370
+
371
+ - **`TYPE`** (offset 3) is the frame kind:
372
+ - a **command echo / ACK** β€” `TYPE` equals the command byte just written
373
+ (`a4/a6/a8/41/…`), so an ACK is simply "the notification whose offset-3 byte
374
+ matches my command" (e.g. `5802 07 a6 …` acks `0xa6`).
375
+ - **`0x57`** β€” a **status** frame; the byte right after `0xc1` is the machine
376
+ *state* (table below).
377
+ - **`0x15` / `0x4b`** β€” idle **heartbeats** (ignored).
378
+ - `0x49` carries a machine-info dump (serial + firmware string); `0x39` etc.
379
+ carry live brew progress (best-effort, not needed for load-only).
380
+
381
+ **State byte** (inside a `0x57` frame, right after `0xc1`):
382
+
383
+ | State | Name | Meaning |
384
+ |-------|--------------------|--------------------------------------|
385
+ | 0x01 | idle | Idle / ready (also at brew end). |
386
+ | 0x1d | loading | Recipe being received. |
387
+ | 0x1f | armed | Recipe loaded, awaiting approval. |
388
+ | 0x1e | awaiting_confirm | Waiting for the human to confirm. |
389
+ | 0x3b | brewing | Brew in progress. |
390
+ | 0x41 | complete | Brew complete. |
391
+
392
+ The load path waits for state **`0x1f` (armed)**, which the machine reports right
393
+ after it ACKs the `0x41` pours frame β€” that's when it prompts the human to approve.
394
+
395
+ ---
396
+
397
+ ## Library API
398
+
399
+ ```python
400
+ import asyncio
401
+ from xbloom_ble import Recipe
402
+ from xbloom_ble.client import XBloomClient, scan
403
+
404
+ async def main():
405
+ recipe = Recipe.from_yaml("recipes/example-washed.yaml")
406
+ devices = await scan()
407
+ async with XBloomClient(devices[0].address) as client:
408
+ await client.load_recipe(recipe) # loads only β€” never starts
409
+ # β†’ now physically approve the brew on the machine
410
+ await client.stream_telemetry(lambda ev: print(ev), duration=300)
411
+
412
+ asyncio.run(main())
413
+ ```
414
+
415
+ `xbloom_ble.protocol` is pure (no BLE) and is the place to start if you want to
416
+ build a different front-end:
417
+
418
+ ```python
419
+ from xbloom_ble.protocol import build_load_frames
420
+ frames = build_load_frames(recipe.to_protocol_dict()) # [a4, a6, a8, 41]
421
+ ```
422
+
423
+ The cloud client (`pip install "xbloom-ble[cloud]"`) pushes to the app account;
424
+ `sync_recipe` is idempotent and only ever manages `AUTO …` recipes:
425
+
426
+ ```python
427
+ from xbloom_ble.cloud import XBloomCloud
428
+ client = XBloomCloud(email="…", password="…") # or XBLOOM_EMAIL/XBLOOM_PASSWORD
429
+ client.login()
430
+ client.sync_recipe(recipe) # create-or-update "AUTO <name>"
431
+ ```
432
+
433
+ ---
434
+
435
+ ## Development
436
+
437
+ ```bash
438
+ pip install -e ".[dev]"
439
+ pytest -q
440
+ ```
441
+
442
+ The protocol tests assert this package's frames are **byte-for-byte** identical
443
+ to the reverse-engineering reference, including the 127-ml split, the center and
444
+ ring patterns, and an agitated bloom. Point `XBLOOM_REFERENCE` at the reference
445
+ script if it lives elsewhere (those comparison tests skip if it's absent).
446
+
447
+ ---
448
+
449
+ ## Disclaimer
450
+
451
+ This is an **unofficial** project. It is **not affiliated with, endorsed by, or
452
+ supported by xBloom** in any way. "xBloom" and "xBloom Studio" are trademarks of
453
+ their respective owner.
454
+
455
+ The protocol here was reverse-engineered and may be incomplete or wrong; it may
456
+ break with firmware updates. **Use at your own risk β€” you assume full
457
+ responsibility** for anything you do with your machine. By design this tool only
458
+ *loads* recipes and **never auto-starts a brew**: the machine always prompts you
459
+ and you approve the brew physically on the device. Even so, supervise your
460
+ machine. No warranty (see [LICENSE](LICENSE)).
461
+
462
+ ## License
463
+
464
+ MIT Β© 2026 Janczykkkko