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.
- pyfitdaysplus-0.0.1a1/LICENSE +22 -0
- pyfitdaysplus-0.0.1a1/PKG-INFO +310 -0
- pyfitdaysplus-0.0.1a1/README.md +282 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/__init__.py +83 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/ble/__init__.py +6 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/ble/backend.py +36 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/ble/transport.py +344 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/client.py +108 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/config.py +28 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/device.py +602 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/events.py +46 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/exceptions.py +19 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/models.py +338 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/protocol/__init__.py +119 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/protocol/commands.py +66 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/protocol/constants.py +45 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/protocol/food_decode.py +181 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/protocol/food_write.py +275 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/protocol/framing.py +77 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/protocol/notify.py +278 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/protocol/nutrition.py +60 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus/py.typed +0 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus.egg-info/PKG-INFO +310 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus.egg-info/SOURCES.txt +35 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus.egg-info/dependency_links.txt +1 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus.egg-info/requires.txt +1 -0
- pyfitdaysplus-0.0.1a1/pyfitdaysplus.egg-info/top_level.txt +1 -0
- pyfitdaysplus-0.0.1a1/pyproject.toml +129 -0
- pyfitdaysplus-0.0.1a1/setup.cfg +4 -0
- pyfitdaysplus-0.0.1a1/tests/test_capabilities.py +125 -0
- pyfitdaysplus-0.0.1a1/tests/test_client.py +53 -0
- pyfitdaysplus-0.0.1a1/tests/test_device_events.py +228 -0
- pyfitdaysplus-0.0.1a1/tests/test_food_write.py +201 -0
- pyfitdaysplus-0.0.1a1/tests/test_framing.py +99 -0
- pyfitdaysplus-0.0.1a1/tests/test_notify.py +245 -0
- pyfitdaysplus-0.0.1a1/tests/test_transport.py +255 -0
- 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"
|