pyfitdaysplus 0.0.1a1__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.
Files changed (37) hide show
  1. pyfitdaysplus-0.0.1a1/LICENSE +22 -0
  2. pyfitdaysplus-0.0.1a1/PKG-INFO +310 -0
  3. pyfitdaysplus-0.0.1a1/README.md +282 -0
  4. pyfitdaysplus-0.0.1a1/pyfitdaysplus/__init__.py +83 -0
  5. pyfitdaysplus-0.0.1a1/pyfitdaysplus/ble/__init__.py +6 -0
  6. pyfitdaysplus-0.0.1a1/pyfitdaysplus/ble/backend.py +36 -0
  7. pyfitdaysplus-0.0.1a1/pyfitdaysplus/ble/transport.py +344 -0
  8. pyfitdaysplus-0.0.1a1/pyfitdaysplus/client.py +108 -0
  9. pyfitdaysplus-0.0.1a1/pyfitdaysplus/config.py +28 -0
  10. pyfitdaysplus-0.0.1a1/pyfitdaysplus/device.py +602 -0
  11. pyfitdaysplus-0.0.1a1/pyfitdaysplus/events.py +46 -0
  12. pyfitdaysplus-0.0.1a1/pyfitdaysplus/exceptions.py +19 -0
  13. pyfitdaysplus-0.0.1a1/pyfitdaysplus/models.py +338 -0
  14. pyfitdaysplus-0.0.1a1/pyfitdaysplus/protocol/__init__.py +119 -0
  15. pyfitdaysplus-0.0.1a1/pyfitdaysplus/protocol/commands.py +66 -0
  16. pyfitdaysplus-0.0.1a1/pyfitdaysplus/protocol/constants.py +45 -0
  17. pyfitdaysplus-0.0.1a1/pyfitdaysplus/protocol/food_decode.py +181 -0
  18. pyfitdaysplus-0.0.1a1/pyfitdaysplus/protocol/food_write.py +275 -0
  19. pyfitdaysplus-0.0.1a1/pyfitdaysplus/protocol/framing.py +77 -0
  20. pyfitdaysplus-0.0.1a1/pyfitdaysplus/protocol/notify.py +278 -0
  21. pyfitdaysplus-0.0.1a1/pyfitdaysplus/protocol/nutrition.py +60 -0
  22. pyfitdaysplus-0.0.1a1/pyfitdaysplus/py.typed +0 -0
  23. pyfitdaysplus-0.0.1a1/pyfitdaysplus.egg-info/PKG-INFO +310 -0
  24. pyfitdaysplus-0.0.1a1/pyfitdaysplus.egg-info/SOURCES.txt +35 -0
  25. pyfitdaysplus-0.0.1a1/pyfitdaysplus.egg-info/dependency_links.txt +1 -0
  26. pyfitdaysplus-0.0.1a1/pyfitdaysplus.egg-info/requires.txt +1 -0
  27. pyfitdaysplus-0.0.1a1/pyfitdaysplus.egg-info/top_level.txt +1 -0
  28. pyfitdaysplus-0.0.1a1/pyproject.toml +129 -0
  29. pyfitdaysplus-0.0.1a1/setup.cfg +4 -0
  30. pyfitdaysplus-0.0.1a1/tests/test_capabilities.py +125 -0
  31. pyfitdaysplus-0.0.1a1/tests/test_client.py +53 -0
  32. pyfitdaysplus-0.0.1a1/tests/test_device_events.py +228 -0
  33. pyfitdaysplus-0.0.1a1/tests/test_food_write.py +201 -0
  34. pyfitdaysplus-0.0.1a1/tests/test_framing.py +99 -0
  35. pyfitdaysplus-0.0.1a1/tests/test_notify.py +245 -0
  36. pyfitdaysplus-0.0.1a1/tests/test_transport.py +255 -0
  37. pyfitdaysplus-0.0.1a1/tests/test_voice_hooks.py +50 -0
@@ -0,0 +1,22 @@
1
+
2
+ MIT License
3
+
4
+ Copyright (c) 2026 pantherale0
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,310 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyfitdaysplus
3
+ Version: 0.0.1a1
4
+ Summary: Async BLE client for ICOMON / Fitdays+ kitchen scales (protocol 113 GeneralV2).
5
+ Author-email: pantherale0 <jordan@hrvy.uk>
6
+ License-Expression: MIT
7
+ Project-URL: Bug Tracker, https://github.com/pantherale0/pyfitdaysplus/issues
8
+ Project-URL: repository, https://github.com/pantherale0/pyfitdaysplus
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Natural Language :: English
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Classifier: Topic :: System :: Hardware
22
+ Classifier: Topic :: System :: Networking
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: bleak>=1.0
27
+ Dynamic: license-file
28
+
29
+ # pyfitdaysplus
30
+
31
+ Async, fully typed Python library for the **ICOMON / Fitdays+** smart kitchen scale **KG2458ULB-D** (BLE name **`MY_SCALE`**, protocol **113 GeneralV2**).
32
+
33
+ Generated from [`pantherale0/python-library-template`](https://github.com/pantherale0/python-library-template) via Copier.
34
+
35
+ ## Supported hardware
36
+
37
+ | Field | Value |
38
+ | --- | --- |
39
+ | Model | `KG2458ULB-D` |
40
+ | BLE name | `MY_SCALE` |
41
+ | Example MAC | `78:66:A5:D3:47:1E` |
42
+ | Firmware (observed) | 1.5.3 |
43
+ | Hardware (observed) | 1.0.0 |
44
+ | Wire `device_type` | `0x42` (protocol 113) |
45
+ | On-device voice | Wake phrase **“Hello Vita”** (English); ~500 foods; ASR on scale |
46
+
47
+ GATT service `FFB0` with write `FFB1`, notify `FFB2`, file write `FFB4`, and DIS `180A`. Characteristics are discovered by UUID — handles are not hardcoded.
48
+
49
+ ## v1 scope
50
+
51
+ This release focuses on **weight, tare, unit**, **General/V2 framing**,
52
+ **decoding voice food selections** from notify **`0xAF`**, and **Phase 2v2
53
+ stubs** for sending custom food + nutrition **to** the device. Voice recognition
54
+ runs **on the scale microphone** (offline ASR, wake **“Hello Vita”**, English,
55
+ ~500 foods); the client only receives food IDs over BLE — **no phone mic** and
56
+ **no PCM/audio streaming** over GATT.
57
+
58
+ Optional hooks also parse **`0xA0` (`funInfo`)** capability bits. Wake-word
59
+ triggering and audio transport are **not implemented**.
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ pip install pyfitdaysplus
65
+ # or from a clone
66
+ uv sync
67
+ ```
68
+
69
+ Requires Python 3.10+, [`bleak`](https://github.com/hbldh/bleak) for BLE, and a Linux/macOS/Windows host with Bluetooth.
70
+
71
+ ## Quick start
72
+
73
+ ```python
74
+ import asyncio
75
+ from pyfitdaysplus import KitchenScaleClient, Unit
76
+
77
+
78
+ async def main() -> None:
79
+ client = KitchenScaleClient()
80
+ device = await client.scan_for_device(name="MY_SCALE")
81
+ # or: device = await client.scan_for_device(address="78:66:A5:D3:47:1E")
82
+
83
+ async with device:
84
+ reading = await device.async_get_weight()
85
+ print(f"{reading.grams:.1f} g")
86
+ await device.tare()
87
+ await device.set_unit(Unit.G)
88
+
89
+
90
+ asyncio.run(main())
91
+ ```
92
+
93
+ ### Sync cache and event callbacks
94
+
95
+ Notifications update an in-memory cache as they arrive. Sync code can read
96
+ `device.weight` (or `device.battery`, `device.food`, `device.ack`) without
97
+ `await`, and you can subscribe to live updates:
98
+
99
+ ```python
100
+ from pyfitdaysplus import Event
101
+
102
+
103
+ def on_weight(reading):
104
+ print(f"{reading.grams:.1f} g, stable={reading.stable}")
105
+
106
+
107
+ unsubscribe = device.subscribe(Event.WEIGHT, on_weight)
108
+
109
+
110
+ def on_device_confirm(reading):
111
+ print(f"on-device confirm {reading.grams:.1f} g")
112
+
113
+
114
+ unsubscribe_confirm = device.subscribe(Event.ON_DEVICE_CONFIRM, on_device_confirm)
115
+
116
+ # From sync code (e.g. a UI timer or callback):
117
+ reading = device.weight
118
+ grams = None if reading is None else reading.grams
119
+
120
+ unsubscribe() # stop receiving callbacks
121
+ unsubscribe_confirm()
122
+ ```
123
+
124
+ Example scripts (shared `--name` / `--address` / `-v`):
125
+
126
+ | Script | What it does |
127
+ | --- | --- |
128
+ | `examples/read_weight.py` | Stream live weight (`--tare`, `--unit G`, `--send-food`, `--seconds`) |
129
+ | `examples/show_capabilities.py` | Print `CompatibilityFlag` after `probe_compatibility()` |
130
+ | `examples/cycle_units.py` | Walk `set_unit` through kitchen units |
131
+ | `examples/listen_voice.py` | Print `0xAF` food-selection notifies (“Hello Vita”) |
132
+
133
+ ```bash
134
+ uv run python examples/read_weight.py --name MY_SCALE
135
+ uv run python examples/read_weight.py --tare --unit G
136
+ uv run python examples/read_weight.py --send-food --seconds 60
137
+ uv run python examples/show_capabilities.py --address 78:66:A5:D3:47:1E
138
+ uv run python examples/cycle_units.py --name MY_SCALE
139
+ uv run python examples/listen_voice.py --name MY_SCALE
140
+ ```
141
+
142
+ ## Public API
143
+
144
+ - `KitchenScaleClient.scan_for_device(name=..., address=...)`
145
+ - `Device.connect()` / `disconnect()` / async context manager
146
+ - `device.weight` / `device.battery` / `device.food` / `device.ack` — sync caches
147
+ - `await device.async_get_weight()` — cached reading, or wait for the first notify
148
+ - `device.subscribe(Event.WEIGHT, callback)` — event callbacks (returns unsubscribe)
149
+ - `device.subscribe(Event.ON_DEVICE_CONFIRM, callback)` — front-panel ✓ after a food upload (`0xAC` on KG2458; **one confirm per upload**)
150
+ - `device.subscribe(Event.FOOD, callback)` / `subscribe(Event.CAPABILITIES, …)` / `subscribe(Event.BATTERY, …)`
151
+ - `async for reading in device.weights(): ...`
152
+ - `await device.tare()`
153
+ - `await device.confirm()` — D2 type 10 (app “confirm food”; the front-panel ✓ is `Event.ON_DEVICE_CONFIRM`)
154
+ - `await device.set_unit(Unit.G)` (also `ML`, `LB`, `OZ`, …)
155
+ - `await device.read_food_selection()` → `FoodInfoNotify` with `count` / `foods`
156
+ - `async for notify in device.food_selections():` — `notify.foods` is `foodId` + `food_index`
157
+ - `await device.set_nutrition(food_id, facts)` — cmd **213 / D5**
158
+ - `await device.set_common_food(food)` — cmd **214 / D6** (split when long)
159
+ - `await device.set_common_food_indexed(food_index, food)` — cmd **215 / D7**
160
+ - `await device.delete_common_foods(entries)` — cmd **220 / DC** on protocol 113
161
+ - Low-level encode helpers: `build_set_nutrition_frame`, `encode_nutrition_value_u24`, …
162
+ - `device.capabilities` / `parse_fun_info` — vendor `DeviceFunction` bits plus
163
+ `CompatibilityFlag` (`caps.flags`, `caps.supports(CompatibilityFlag.NUTRITION)`)
164
+ - `device.battery` / `caps.battery` — percent from ``funInfo`` (`0xA0`)
165
+ - `await device.probe_compatibility()` — merge funInfo with GATT (FFB4, Nordic DFU)
166
+ and live weight, without extra command writes
167
+ - Injectable BLE backend via `KitchenScaleClient(backend=...)` for tests
168
+
169
+ No raw UUIDs or wire command bytes are required for normal kitchen-scale use.
170
+
171
+ ### On-device voice food selection
172
+
173
+ The **KG2458ULB-D** microphone runs offline AI food recognition locally (wake
174
+ **“Hello Vita”**). Fitdays+ handles notify **`0xAF` / 175** only after native
175
+ **`libICBleProtocol.so`** decodes BLE bytes into a Java map:
176
+
177
+ - `count` (int)
178
+ - `foods`: list of `{ foodId, foodIndex }`
179
+ - empty when `count == 0`
180
+
181
+ Java never sees raw offsets. This library unwraps splitData the same way and
182
+ fills **`count` / `foods`**. Native packing is ``count u8`` then
183
+ ``foodIndex u8 | foodId u32 BE`` per hit (identical to delete D8/DC).
184
+
185
+ ```python
186
+ from pyfitdaysplus import Event, KitchenScaleClient, parse_food_info_notify
187
+
188
+ client = KitchenScaleClient()
189
+ device = await client.scan_for_device(name="MY_SCALE")
190
+
191
+
192
+ def on_voice_food(notify):
193
+ print(notify.raw_payload.hex())
194
+ for food in notify.foods:
195
+ print(food.food_id, food.food_index)
196
+
197
+
198
+ device.subscribe(Event.FOOD, on_voice_food)
199
+
200
+ async with device:
201
+ notify = await device.read_food_selection()
202
+ parsed = parse_food_info_notify(notify.raw_payload)
203
+ ```
204
+
205
+ We do **not** stream audio or inject the **“Hello Vita”** wake phrase over BLE.
206
+
207
+ ### Writing custom food + nutrition (recommended)
208
+
209
+ **Upload a food with your nutrition facts, then enter food-weigh mode.** Do
210
+ that **before** each ✓. The scale’s LCD may still show a firmware catalog
211
+ name (live KG2458 used USDA-style ids, e.g. 1077 → “MILK WHOLE”). Trust the
212
+ macros you just sent, not the onboard US table. After ✓ the scale saves
213
+ once (`Event.ON_DEVICE_CONFIRM` / history ``0xAC``) and will not confirm
214
+ again until you upload another food.
215
+
216
+ ```python
217
+ from pyfitdaysplus import CommonFood, Event, NutritionFact, NutritionFactType
218
+
219
+ food = CommonFood(
220
+ food_id=42,
221
+ name="Oats",
222
+ weight=100,
223
+ facts=(NutritionFact(NutritionFactType.PROTEIN, 12.0),),
224
+ )
225
+
226
+
227
+ def on_device_confirm(reading):
228
+ print(reading.grams, reading.food_id)
229
+
230
+
231
+ async with device:
232
+ device.subscribe(Event.ON_DEVICE_CONFIRM, on_device_confirm)
233
+ await device.set_common_food(food)
234
+ await device.set_nutrition(food.food_id, list(food.facts))
235
+ # weigh, press ✓ → on_device_confirm once
236
+ # upload again before the next ✓
237
+ ```
238
+
239
+ | Method | Cmd | Notes |
240
+ | --- | --- | --- |
241
+ | `set_nutrition(food_id, facts)` | 213 / D5 | facts only (no LCD name); native ×10 |
242
+ | `set_common_food(food)` | 214 / D6 | splitData chunks: `total_len \| seq \| slice` |
243
+ | `set_common_food_indexed(food_index, food)` | 215 / D7 | `food_index` prefixes logical payload |
244
+ | `delete_common_foods(entries)` | 220 / DC | Protocol 113 default; pass `use_alt_delete=False` for 216 / D8 |
245
+
246
+ D5 native ×10 (150 kcal → wire 1500); D6/D7 default ×100; pass `scale=1.0` for raw integers.
247
+
248
+ **Still stubbed**
249
+
250
+ - **FFB4** icon file upload (D9 metadata is known; chunks cmd 65440 not sent).
251
+ - D6 reassembled layout: `foodId u32 | name | icon | weight u16 | fact_count | facts`
252
+ - splitData per chunk: `total_len u16 | seq u8 | slice` (see `docs/kitchen_ble_framing.md`)
253
+
254
+ ## Protocol notes
255
+
256
+ General/V2 frames use magic `0xAC`, `device_type`, payload, trailing command byte, and an **8-bit additive checksum** (not CRC16) over bytes from index 2 through `len-2`.
257
+
258
+ Verified TX vectors for `device_type=0x42`:
259
+
260
+ | Command | Hex |
261
+ | --- | --- |
262
+ | `app_reply` (209 / D1) | `ac42000200a000d173` |
263
+ | `read_history` (212 / D4) | `ac42000000d4d4` |
264
+ | `tare` (210 / D2, type 0) | built via setting path |
265
+
266
+ Live weight arrives on notify type **`0xA6`** (`ICKitchenScaleData`). 14-byte
267
+ splitData body:
268
+
269
+ | Offset | Field |
270
+ | --- | --- |
271
+ | 0 | flags: `0x80` unstable/negative, `0x40` tare; idle frames also set `0x01` |
272
+ | 1 | unit ordinal in the high nibble (`unit << 4`) |
273
+ | 2–4 | milligrams u24 BE (Fitdays field `b`) |
274
+ | 5–8 | `foodId` u32 BE (firmware catalog; 0 when idle) |
275
+ | 9–12 | `userId` u32 BE |
276
+ | 13 | `isOk` (front-panel ✓ does **not** set this on KG2458; use `Event.ON_DEVICE_CONFIRM` / history `0xAC`) |
277
+
278
+ Voice food selection uses notify **`0xAF`** (`ICFoodInfo`):
279
+ `count u8 | (foodIndex u8 + foodId u32 BE)…`. Java maps still use
280
+ `foods[{ foodId, foodIndex }]`.
281
+
282
+ User info for firmware ≥ 66 is cmd **219 / DB**: `time u32`, `utc_offset u16`,
283
+ `userId u32`, `rnis` count, then each `{ type u8, cur_rni u24 ×10, max_rni u24 ×10, progress u16 }`. Older firmware uses cmd **208** without the `rnis` list.
284
+
285
+ File-info cmd **217 / D9** (before FFB4): `fileType u8`, `foodIndex u8`,
286
+ `fileSize u32`, `foodId u32`, `cs u8`.
287
+
288
+ ## Known unknowns
289
+
290
+ - Remaining ``funInfo`` (`0xA0`) precision bytes after the flag u32 (`divG` /
291
+ `divOZ` / `maxG` / liquid units). Flags + battery percent (offset 15) are parsed
292
+ - No kitchen BLE **voice-language** command; `ICDeviceFunctionVoiceLanguage` is
293
+ bit 4 and is **clear** on live KG2458 (`0x00fc4f02`). Other SKUs use body-scale
294
+ sound-mode UI
295
+ - **“Hello Vita”** ASR is on-device only; no GATT PCM/audio stream in the SDK
296
+ - **FFB4** file chunks after D9 are not implemented (inline D6/D7 `icon` only)
297
+ - Legacy protocols **110/111** (stubs only via shared models)
298
+ - BLE **advertisement manufacturer data** (scan matches `local_name` only)
299
+
300
+ ## Development
301
+
302
+ ```bash
303
+ uv run pytest
304
+ uv run mypy pyfitdaysplus
305
+ uv run ruff check .
306
+ ```
307
+
308
+ ## License
309
+
310
+ MIT
@@ -0,0 +1,282 @@
1
+ # pyfitdaysplus
2
+
3
+ Async, fully typed Python library for the **ICOMON / Fitdays+** smart kitchen scale **KG2458ULB-D** (BLE name **`MY_SCALE`**, protocol **113 GeneralV2**).
4
+
5
+ Generated from [`pantherale0/python-library-template`](https://github.com/pantherale0/python-library-template) via Copier.
6
+
7
+ ## Supported hardware
8
+
9
+ | Field | Value |
10
+ | --- | --- |
11
+ | Model | `KG2458ULB-D` |
12
+ | BLE name | `MY_SCALE` |
13
+ | Example MAC | `78:66:A5:D3:47:1E` |
14
+ | Firmware (observed) | 1.5.3 |
15
+ | Hardware (observed) | 1.0.0 |
16
+ | Wire `device_type` | `0x42` (protocol 113) |
17
+ | On-device voice | Wake phrase **“Hello Vita”** (English); ~500 foods; ASR on scale |
18
+
19
+ GATT service `FFB0` with write `FFB1`, notify `FFB2`, file write `FFB4`, and DIS `180A`. Characteristics are discovered by UUID — handles are not hardcoded.
20
+
21
+ ## v1 scope
22
+
23
+ This release focuses on **weight, tare, unit**, **General/V2 framing**,
24
+ **decoding voice food selections** from notify **`0xAF`**, and **Phase 2v2
25
+ stubs** for sending custom food + nutrition **to** the device. Voice recognition
26
+ runs **on the scale microphone** (offline ASR, wake **“Hello Vita”**, English,
27
+ ~500 foods); the client only receives food IDs over BLE — **no phone mic** and
28
+ **no PCM/audio streaming** over GATT.
29
+
30
+ Optional hooks also parse **`0xA0` (`funInfo`)** capability bits. Wake-word
31
+ triggering and audio transport are **not implemented**.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install pyfitdaysplus
37
+ # or from a clone
38
+ uv sync
39
+ ```
40
+
41
+ Requires Python 3.10+, [`bleak`](https://github.com/hbldh/bleak) for BLE, and a Linux/macOS/Windows host with Bluetooth.
42
+
43
+ ## Quick start
44
+
45
+ ```python
46
+ import asyncio
47
+ from pyfitdaysplus import KitchenScaleClient, Unit
48
+
49
+
50
+ async def main() -> None:
51
+ client = KitchenScaleClient()
52
+ device = await client.scan_for_device(name="MY_SCALE")
53
+ # or: device = await client.scan_for_device(address="78:66:A5:D3:47:1E")
54
+
55
+ async with device:
56
+ reading = await device.async_get_weight()
57
+ print(f"{reading.grams:.1f} g")
58
+ await device.tare()
59
+ await device.set_unit(Unit.G)
60
+
61
+
62
+ asyncio.run(main())
63
+ ```
64
+
65
+ ### Sync cache and event callbacks
66
+
67
+ Notifications update an in-memory cache as they arrive. Sync code can read
68
+ `device.weight` (or `device.battery`, `device.food`, `device.ack`) without
69
+ `await`, and you can subscribe to live updates:
70
+
71
+ ```python
72
+ from pyfitdaysplus import Event
73
+
74
+
75
+ def on_weight(reading):
76
+ print(f"{reading.grams:.1f} g, stable={reading.stable}")
77
+
78
+
79
+ unsubscribe = device.subscribe(Event.WEIGHT, on_weight)
80
+
81
+
82
+ def on_device_confirm(reading):
83
+ print(f"on-device confirm {reading.grams:.1f} g")
84
+
85
+
86
+ unsubscribe_confirm = device.subscribe(Event.ON_DEVICE_CONFIRM, on_device_confirm)
87
+
88
+ # From sync code (e.g. a UI timer or callback):
89
+ reading = device.weight
90
+ grams = None if reading is None else reading.grams
91
+
92
+ unsubscribe() # stop receiving callbacks
93
+ unsubscribe_confirm()
94
+ ```
95
+
96
+ Example scripts (shared `--name` / `--address` / `-v`):
97
+
98
+ | Script | What it does |
99
+ | --- | --- |
100
+ | `examples/read_weight.py` | Stream live weight (`--tare`, `--unit G`, `--send-food`, `--seconds`) |
101
+ | `examples/show_capabilities.py` | Print `CompatibilityFlag` after `probe_compatibility()` |
102
+ | `examples/cycle_units.py` | Walk `set_unit` through kitchen units |
103
+ | `examples/listen_voice.py` | Print `0xAF` food-selection notifies (“Hello Vita”) |
104
+
105
+ ```bash
106
+ uv run python examples/read_weight.py --name MY_SCALE
107
+ uv run python examples/read_weight.py --tare --unit G
108
+ uv run python examples/read_weight.py --send-food --seconds 60
109
+ uv run python examples/show_capabilities.py --address 78:66:A5:D3:47:1E
110
+ uv run python examples/cycle_units.py --name MY_SCALE
111
+ uv run python examples/listen_voice.py --name MY_SCALE
112
+ ```
113
+
114
+ ## Public API
115
+
116
+ - `KitchenScaleClient.scan_for_device(name=..., address=...)`
117
+ - `Device.connect()` / `disconnect()` / async context manager
118
+ - `device.weight` / `device.battery` / `device.food` / `device.ack` — sync caches
119
+ - `await device.async_get_weight()` — cached reading, or wait for the first notify
120
+ - `device.subscribe(Event.WEIGHT, callback)` — event callbacks (returns unsubscribe)
121
+ - `device.subscribe(Event.ON_DEVICE_CONFIRM, callback)` — front-panel ✓ after a food upload (`0xAC` on KG2458; **one confirm per upload**)
122
+ - `device.subscribe(Event.FOOD, callback)` / `subscribe(Event.CAPABILITIES, …)` / `subscribe(Event.BATTERY, …)`
123
+ - `async for reading in device.weights(): ...`
124
+ - `await device.tare()`
125
+ - `await device.confirm()` — D2 type 10 (app “confirm food”; the front-panel ✓ is `Event.ON_DEVICE_CONFIRM`)
126
+ - `await device.set_unit(Unit.G)` (also `ML`, `LB`, `OZ`, …)
127
+ - `await device.read_food_selection()` → `FoodInfoNotify` with `count` / `foods`
128
+ - `async for notify in device.food_selections():` — `notify.foods` is `foodId` + `food_index`
129
+ - `await device.set_nutrition(food_id, facts)` — cmd **213 / D5**
130
+ - `await device.set_common_food(food)` — cmd **214 / D6** (split when long)
131
+ - `await device.set_common_food_indexed(food_index, food)` — cmd **215 / D7**
132
+ - `await device.delete_common_foods(entries)` — cmd **220 / DC** on protocol 113
133
+ - Low-level encode helpers: `build_set_nutrition_frame`, `encode_nutrition_value_u24`, …
134
+ - `device.capabilities` / `parse_fun_info` — vendor `DeviceFunction` bits plus
135
+ `CompatibilityFlag` (`caps.flags`, `caps.supports(CompatibilityFlag.NUTRITION)`)
136
+ - `device.battery` / `caps.battery` — percent from ``funInfo`` (`0xA0`)
137
+ - `await device.probe_compatibility()` — merge funInfo with GATT (FFB4, Nordic DFU)
138
+ and live weight, without extra command writes
139
+ - Injectable BLE backend via `KitchenScaleClient(backend=...)` for tests
140
+
141
+ No raw UUIDs or wire command bytes are required for normal kitchen-scale use.
142
+
143
+ ### On-device voice food selection
144
+
145
+ The **KG2458ULB-D** microphone runs offline AI food recognition locally (wake
146
+ **“Hello Vita”**). Fitdays+ handles notify **`0xAF` / 175** only after native
147
+ **`libICBleProtocol.so`** decodes BLE bytes into a Java map:
148
+
149
+ - `count` (int)
150
+ - `foods`: list of `{ foodId, foodIndex }`
151
+ - empty when `count == 0`
152
+
153
+ Java never sees raw offsets. This library unwraps splitData the same way and
154
+ fills **`count` / `foods`**. Native packing is ``count u8`` then
155
+ ``foodIndex u8 | foodId u32 BE`` per hit (identical to delete D8/DC).
156
+
157
+ ```python
158
+ from pyfitdaysplus import Event, KitchenScaleClient, parse_food_info_notify
159
+
160
+ client = KitchenScaleClient()
161
+ device = await client.scan_for_device(name="MY_SCALE")
162
+
163
+
164
+ def on_voice_food(notify):
165
+ print(notify.raw_payload.hex())
166
+ for food in notify.foods:
167
+ print(food.food_id, food.food_index)
168
+
169
+
170
+ device.subscribe(Event.FOOD, on_voice_food)
171
+
172
+ async with device:
173
+ notify = await device.read_food_selection()
174
+ parsed = parse_food_info_notify(notify.raw_payload)
175
+ ```
176
+
177
+ We do **not** stream audio or inject the **“Hello Vita”** wake phrase over BLE.
178
+
179
+ ### Writing custom food + nutrition (recommended)
180
+
181
+ **Upload a food with your nutrition facts, then enter food-weigh mode.** Do
182
+ that **before** each ✓. The scale’s LCD may still show a firmware catalog
183
+ name (live KG2458 used USDA-style ids, e.g. 1077 → “MILK WHOLE”). Trust the
184
+ macros you just sent, not the onboard US table. After ✓ the scale saves
185
+ once (`Event.ON_DEVICE_CONFIRM` / history ``0xAC``) and will not confirm
186
+ again until you upload another food.
187
+
188
+ ```python
189
+ from pyfitdaysplus import CommonFood, Event, NutritionFact, NutritionFactType
190
+
191
+ food = CommonFood(
192
+ food_id=42,
193
+ name="Oats",
194
+ weight=100,
195
+ facts=(NutritionFact(NutritionFactType.PROTEIN, 12.0),),
196
+ )
197
+
198
+
199
+ def on_device_confirm(reading):
200
+ print(reading.grams, reading.food_id)
201
+
202
+
203
+ async with device:
204
+ device.subscribe(Event.ON_DEVICE_CONFIRM, on_device_confirm)
205
+ await device.set_common_food(food)
206
+ await device.set_nutrition(food.food_id, list(food.facts))
207
+ # weigh, press ✓ → on_device_confirm once
208
+ # upload again before the next ✓
209
+ ```
210
+
211
+ | Method | Cmd | Notes |
212
+ | --- | --- | --- |
213
+ | `set_nutrition(food_id, facts)` | 213 / D5 | facts only (no LCD name); native ×10 |
214
+ | `set_common_food(food)` | 214 / D6 | splitData chunks: `total_len \| seq \| slice` |
215
+ | `set_common_food_indexed(food_index, food)` | 215 / D7 | `food_index` prefixes logical payload |
216
+ | `delete_common_foods(entries)` | 220 / DC | Protocol 113 default; pass `use_alt_delete=False` for 216 / D8 |
217
+
218
+ D5 native ×10 (150 kcal → wire 1500); D6/D7 default ×100; pass `scale=1.0` for raw integers.
219
+
220
+ **Still stubbed**
221
+
222
+ - **FFB4** icon file upload (D9 metadata is known; chunks cmd 65440 not sent).
223
+ - D6 reassembled layout: `foodId u32 | name | icon | weight u16 | fact_count | facts`
224
+ - splitData per chunk: `total_len u16 | seq u8 | slice` (see `docs/kitchen_ble_framing.md`)
225
+
226
+ ## Protocol notes
227
+
228
+ General/V2 frames use magic `0xAC`, `device_type`, payload, trailing command byte, and an **8-bit additive checksum** (not CRC16) over bytes from index 2 through `len-2`.
229
+
230
+ Verified TX vectors for `device_type=0x42`:
231
+
232
+ | Command | Hex |
233
+ | --- | --- |
234
+ | `app_reply` (209 / D1) | `ac42000200a000d173` |
235
+ | `read_history` (212 / D4) | `ac42000000d4d4` |
236
+ | `tare` (210 / D2, type 0) | built via setting path |
237
+
238
+ Live weight arrives on notify type **`0xA6`** (`ICKitchenScaleData`). 14-byte
239
+ splitData body:
240
+
241
+ | Offset | Field |
242
+ | --- | --- |
243
+ | 0 | flags: `0x80` unstable/negative, `0x40` tare; idle frames also set `0x01` |
244
+ | 1 | unit ordinal in the high nibble (`unit << 4`) |
245
+ | 2–4 | milligrams u24 BE (Fitdays field `b`) |
246
+ | 5–8 | `foodId` u32 BE (firmware catalog; 0 when idle) |
247
+ | 9–12 | `userId` u32 BE |
248
+ | 13 | `isOk` (front-panel ✓ does **not** set this on KG2458; use `Event.ON_DEVICE_CONFIRM` / history `0xAC`) |
249
+
250
+ Voice food selection uses notify **`0xAF`** (`ICFoodInfo`):
251
+ `count u8 | (foodIndex u8 + foodId u32 BE)…`. Java maps still use
252
+ `foods[{ foodId, foodIndex }]`.
253
+
254
+ User info for firmware ≥ 66 is cmd **219 / DB**: `time u32`, `utc_offset u16`,
255
+ `userId u32`, `rnis` count, then each `{ type u8, cur_rni u24 ×10, max_rni u24 ×10, progress u16 }`. Older firmware uses cmd **208** without the `rnis` list.
256
+
257
+ File-info cmd **217 / D9** (before FFB4): `fileType u8`, `foodIndex u8`,
258
+ `fileSize u32`, `foodId u32`, `cs u8`.
259
+
260
+ ## Known unknowns
261
+
262
+ - Remaining ``funInfo`` (`0xA0`) precision bytes after the flag u32 (`divG` /
263
+ `divOZ` / `maxG` / liquid units). Flags + battery percent (offset 15) are parsed
264
+ - No kitchen BLE **voice-language** command; `ICDeviceFunctionVoiceLanguage` is
265
+ bit 4 and is **clear** on live KG2458 (`0x00fc4f02`). Other SKUs use body-scale
266
+ sound-mode UI
267
+ - **“Hello Vita”** ASR is on-device only; no GATT PCM/audio stream in the SDK
268
+ - **FFB4** file chunks after D9 are not implemented (inline D6/D7 `icon` only)
269
+ - Legacy protocols **110/111** (stubs only via shared models)
270
+ - BLE **advertisement manufacturer data** (scan matches `local_name` only)
271
+
272
+ ## Development
273
+
274
+ ```bash
275
+ uv run pytest
276
+ uv run mypy pyfitdaysplus
277
+ uv run ruff check .
278
+ ```
279
+
280
+ ## License
281
+
282
+ MIT
@@ -0,0 +1,83 @@
1
+ """pyfitdaysplus — async BLE client for ICOMON / Fitdays+ kitchen scales."""
2
+
3
+ from .client import Client, KitchenScaleClient
4
+ from .config import COMM_PROTOCOL, Config
5
+ from .device import Device, Unsubscribe
6
+ from .events import Event
7
+ from .exceptions import (
8
+ DeviceNotFoundError,
9
+ IcomonKitchenError,
10
+ NotConnectedError,
11
+ ProtocolError,
12
+ )
13
+ from .models import (
14
+ VOICE_WAKE_PHRASE,
15
+ BatteryInfo,
16
+ CommandAck,
17
+ CommonFood,
18
+ CompatibilityFlag,
19
+ DeviceCapabilities,
20
+ DeviceFunction,
21
+ FoodInfo,
22
+ FoodInfoNotify,
23
+ FoodReference,
24
+ NutritionFact,
25
+ NutritionFactType,
26
+ ProtocolVersion,
27
+ ScaleInfo,
28
+ ScannedDevice,
29
+ Unit,
30
+ WeightReading,
31
+ )
32
+ from .protocol.constants import DEFAULT_NUTRITION_SCALE, SET_NUTRITION_SCALE
33
+ from .protocol.food_write import (
34
+ build_delete_common_foods_frame,
35
+ build_set_common_food_frames,
36
+ build_set_common_food_indexed_frames,
37
+ build_set_nutrition_frame,
38
+ )
39
+ from .protocol.notify import parse_food_info_notify, parse_fun_info
40
+ from .protocol.nutrition import encode_nutrition_facts, encode_nutrition_value_u24
41
+
42
+ __all__ = [
43
+ "COMM_PROTOCOL",
44
+ "DEFAULT_NUTRITION_SCALE",
45
+ "SET_NUTRITION_SCALE",
46
+ "VOICE_WAKE_PHRASE",
47
+ "BatteryInfo",
48
+ "Client",
49
+ "CommandAck",
50
+ "CommonFood",
51
+ "CompatibilityFlag",
52
+ "Config",
53
+ "Device",
54
+ "DeviceCapabilities",
55
+ "DeviceFunction",
56
+ "DeviceNotFoundError",
57
+ "Event",
58
+ "FoodInfo",
59
+ "FoodInfoNotify",
60
+ "FoodReference",
61
+ "IcomonKitchenError",
62
+ "KitchenScaleClient",
63
+ "NotConnectedError",
64
+ "NutritionFact",
65
+ "NutritionFactType",
66
+ "ProtocolError",
67
+ "ProtocolVersion",
68
+ "ScaleInfo",
69
+ "ScannedDevice",
70
+ "Unit",
71
+ "Unsubscribe",
72
+ "WeightReading",
73
+ "__version__",
74
+ "build_delete_common_foods_frame",
75
+ "build_set_common_food_frames",
76
+ "build_set_common_food_indexed_frames",
77
+ "build_set_nutrition_frame",
78
+ "encode_nutrition_facts",
79
+ "encode_nutrition_value_u24",
80
+ "parse_food_info_notify",
81
+ "parse_fun_info",
82
+ ]
83
+ __version__ = "0.0.1-alpha.1"