pyfitdaysplus
Async, fully typed Python library for the ICOMON / Fitdays+ smart kitchen scale KG2458ULB-D (BLE name MY_SCALE, protocol 113 GeneralV2).
Generated from pantherale0/python-library-template via Copier.
Supported hardware
| Field | Value |
|---|---|
| Model | KG2458ULB-D |
| BLE name | MY_SCALE |
| Example MAC | 78:66:A5:D3:47:1E |
| Firmware (observed) | 1.5.3 |
| Hardware (observed) | 1.0.0 |
Wire device_type |
0x42 (protocol 113) |
| On-device voice | Wake phrase “Hello Vita” (English); ~500 foods; ASR on scale |
GATT service FFB0 with write FFB1, notify FFB2, file write FFB4, and DIS 180A. Characteristics are discovered by UUID — handles are not hardcoded.
v1 scope
This release focuses on weight, tare, unit, General/V2 framing,
decoding voice food selections from notify 0xAF, and Phase 2v2
stubs for sending custom food + nutrition to the device. Voice recognition
runs on the scale microphone (offline ASR, wake “Hello Vita”, English,
~500 foods); the client only receives food IDs over BLE — no phone mic and
no PCM/audio streaming over GATT.
Optional hooks also parse 0xA0 (funInfo) capability bits. Wake-word
triggering and audio transport are not implemented.
Install
pip install pyfitdaysplus
# or from a clone
uv sync
Requires Python 3.10+, bleak and
bleak-retry-connector
for BLE, and a Linux/macOS/Windows host with Bluetooth.
Quick start
Discovery is not part of this library. Home Assistant can construct a
Device from a stored address while the scale is off, then attach a bleak
BLEDevice when an advertisement arrives:
scale = Device(address="78:66:A5:D3:47:1E")
# later, when the scanner sees the scale:
scale.set_ble_device_and_advertisement_data(ble_device, advertisement)
Standalone scripts pass a bleak BLEDevice from BleakScanner:
import asyncio
from bleak import BleakScanner
from pyfitdaysplus import Device, Unit
async def main() -> None:
ble_device = await BleakScanner.find_device_by_name("MY_SCALE")
# Home Assistant: Device(service_info.device, service_info.advertisement)
scale = Device(ble_device)
async with scale:
reading = await scale.async_get_weight()
print(f"{reading.grams:.1f} g")
await scale.tare()
await scale.set_unit(Unit.G)
asyncio.run(main())
When the scanner path changes (new adapter or Bluetooth proxy), update the
handle without constructing a new Device:
scale.set_ble_device_and_advertisement_data(ble_device, advertisement)
Sync cache and event callbacks
Notifications update an in-memory cache as they arrive. Sync code can read
device.weight (or device.battery, device.food, device.ack, device.history, device.food_weigh) without
await, and you can subscribe to live updates:
from pyfitdaysplus import Event
def on_weight(reading):
print(f"{reading.grams:.1f} g, stable={reading.stable}")
unsubscribe = device.subscribe(Event.WEIGHT, on_weight)
def on_device_confirm(reading):
print(f"on-device confirm {reading.grams:.1f} g")
unsubscribe_confirm = device.subscribe(Event.ON_DEVICE_CONFIRM, on_device_confirm)
# From sync code (e.g. a UI timer or callback):
reading = device.weight
grams = None if reading is None else reading.grams
unsubscribe() # stop receiving callbacks
unsubscribe_confirm()
# Stored ✓ records (not live confirms):
records = await device.read_history()
for reading in records:
print(reading.recorded_at, reading.grams, reading.food_id)
Example scripts (shared --name / --address / -v):
| Script | What it does |
|---|---|
examples/read_weight.py |
Stream live weight (--tare, --unit G, --send-food, --history, --seconds) |
examples/show_capabilities.py |
Print CompatibilityFlag after probe_compatibility() |
examples/cycle_units.py |
Walk set_unit through kitchen units |
examples/listen_voice.py |
Print 0xAF food-selection notifies (“Hello Vita”) |
uv run python examples/read_weight.py --name MY_SCALE
uv run python examples/read_weight.py --tare --unit G
uv run python examples/read_weight.py --send-food --seconds 60
uv run python examples/read_weight.py --history --seconds 0
uv run python examples/show_capabilities.py --address 78:66:A5:D3:47:1E
uv run python examples/cycle_units.py --name MY_SCALE
uv run python examples/listen_voice.py --name MY_SCALE
Public API
Device(ble_device=None, advertisement_data=None, *, address=...)— construct from a bleakBLEDevice, or from a known address before the scale is in rangedevice.set_ble_device_and_advertisement_data(ble_device, advertisement)— refresh the BLE path (Home Assistant)Device.connect()/disconnect()/ async context manager (connects viableak-retry-connector)device.subscribe(Event.CONNECT, callback)/subscribe(Event.DISCONNECT, …)— GATT session lifecycle (scale address)device.weight/device.battery/device.food/device.ack/device.history/device.food_weigh— sync cachesawait device.async_get_weight()— cached reading, or wait for the first notifydevice.subscribe(Event.WEIGHT, callback)— event callbacks (returns unsubscribe)device.subscribe(Event.ON_DEVICE_CONFIRM, callback)— front-panel ✓ (0xACon KG2458; one per armed D6)device.subscribe(Event.HISTORY, callback)— stored0xACrecords duringread_history(not a live ✓)await device.read_history()— D4 dump of on-scale ✓ records (recorded_at, grams,food_id)device.subscribe(Event.FOOD, callback)/subscribe(Event.CAPABILITIES, …)/subscribe(Event.BATTERY, …)async for reading in device.weights(): ...await device.tare()await device.confirm()— D2 type 10 (app “confirm food”; the front-panel ✓ isEvent.ON_DEVICE_CONFIRM)await device.set_unit(Unit.G)(alsoML,LB,OZ, …)await device.read_food_selection()→FoodInfoNotifywithcount/foodsasync for notify in device.food_selections():—notify.foodsisfoodId+food_indexawait device.start_food_weigh(food)/await device.stop_food_weigh()— food-weigh session (D6 arm / re-arm / 0 g clear)device.subscribe(Event.FOOD_WEIGH, callback)— armedCommonFood, orNonewhen the session endsawait device.set_nutrition(food_id, facts)— cmd 213 / D5 (low-level; not used on KG2458 food-weigh)await device.set_common_food(food)— cmd 214 / D6 (low-level write; preferstart_food_weigh)await device.set_common_food_indexed(food_index, food)— cmd 215 / D7await device.delete_common_foods(entries)— cmd 220 / DC on protocol 113- Low-level encode helpers:
build_set_nutrition_frame,encode_nutrition_value_u24, … device.capabilities/parse_fun_info— vendorDeviceFunctionbits plusCompatibilityFlag(caps.flags,caps.supports(CompatibilityFlag.NUTRITION))device.battery/caps.battery— percent fromfunInfo(0xA0)await device.probe_compatibility()— merge funInfo with GATT (FFB4, Nordic DFU) and live weight, without extra command writes
No raw UUIDs or wire command bytes are required for normal kitchen-scale use.
On-device voice food selection
The KG2458ULB-D microphone runs offline AI food recognition locally (wake
“Hello Vita”). Fitdays+ handles notify 0xAF / 175 only after native
libICBleProtocol.so decodes BLE bytes into a Java map:
count(int)foods: list of{ foodId, foodIndex }- empty when
count == 0
Java never sees raw offsets. This library unwraps splitData the same way and
fills count / foods. Native packing is count u8 then
foodIndex u8 | foodId u32 BE per hit (identical to delete D8/DC).
from pyfitdaysplus import Device, Event, parse_food_info_notify
device = Device(ble_device)
def on_voice_food(notify):
print(notify.raw_payload.hex())
for food in notify.foods:
print(food.food_id, food.food_index)
device.subscribe(Event.FOOD, on_voice_food)
async with device:
notify = await device.read_food_selection()
parsed = parse_food_info_notify(notify.raw_payload)
We do not stream audio or inject the “Hello Vita” wake phrase over BLE.
Writing custom food + nutrition (recommended)
Start a food-weigh session; the library talks to the scale the way Fitdays+ does.
Select a food (D6 even at 0 g). When a stable weight appears, D6 is sent again.
Front-panel ✓ fires Event.ON_DEVICE_CONFIRM and the same food is re-armed.
When the plate returns to 0 g, the session clears (FOOD_WEIGH_CLEAR). Do not
send D5 or re-upload from Home Assistant yourself.
The LCD may still show a firmware catalog name (live KG2458 used USDA-style ids,
e.g. 1077 → “MILK WHOLE”). Trust the macros on the CommonFood you passed.
from pyfitdaysplus import CommonFood, Event, NutritionFact, NutritionFactType
food = CommonFood(
food_id=42,
name="Oats",
weight=100,
facts=(NutritionFact(NutritionFactType.PROTEIN, 12.0),),
)
def on_device_confirm(reading):
print(reading.grams, reading.food_id)
async with device:
device.subscribe(Event.ON_DEVICE_CONFIRM, on_device_confirm)
await device.start_food_weigh(food)
# weigh, press ✓ → on_device_confirm; session stays armed until 0 g
await device.stop_food_weigh() # optional; 0 g also clears
| Method | Cmd | Notes |
|---|---|---|
start_food_weigh(food) |
214 / D6 | session: arm, re-arm after ✓, clear at 0 g |
stop_food_weigh() |
214 / D6 | clear (foodId=0, empty name, 100 g) |
set_nutrition(food_id, facts) |
213 / D5 | facts only; native ×10; not sent by KG2458 food-weigh |
set_common_food(food) |
214 / D6 | low-level splitData write |
set_common_food_indexed(food_index, food) |
215 / D7 | food_index prefixes logical payload |
delete_common_foods(entries) |
220 / DC | Protocol 113 default; pass use_alt_delete=False for 216 / D8 |
D5 native ×10 (150 kcal → wire 1500); D6/D7 default ×100; pass scale=1.0 for raw integers.
Still stubbed
- FFB4 icon file upload (D9 metadata is known; chunks cmd 65440 not sent).
- D6 reassembled layout:
foodId u32 | name | icon | weight u16 | fact_count | facts - splitData per chunk:
total_len u16 | seq u8 | slice(seedocs/kitchen_ble_framing.md)
Protocol notes
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.
Verified TX vectors for device_type=0x42:
| Command | Hex |
|---|---|
app_reply (209 / D1) |
ac42000200a000d173 (funInfo) / ac42000200ac00d17f (history 0xAC) |
read_history (212 / D4) |
ac42000000d4d4 |
tare (210 / D2, type 0) |
built via setting path |
Live weight arrives on notify type 0xA6 (ICKitchenScaleData). 14-byte
splitData body:
| Offset | Field |
|---|---|
| 0 | flags: 0x80 unstable/negative, 0x40 tare; idle frames also set 0x01 |
| 1 | unit ordinal in the high nibble (unit << 4) |
| 2–4 | milligrams u24 BE (Fitdays field b) |
| 5–8 | foodId u32 BE (firmware catalog; 0 when idle) |
| 9–12 | userId u32 BE |
| 13 | isOk (front-panel ✓ does not set this on KG2458; use Event.ON_DEVICE_CONFIRM / history 0xAC) |
Voice food selection uses notify 0xAF (ICFoodInfo):
count u8 | (foodIndex u8 + foodId u32 BE)…. Java maps still use
foods[{ foodId, foodIndex }].
User info for firmware ≥ 66 is cmd 219 / DB: time u32, utc_offset u16,
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.
File-info cmd 217 / D9 (before FFB4): fileType u8, foodIndex u8,
fileSize u32, foodId u32, cs u8.
Known unknowns
- Remaining
funInfo(0xA0) precision bytes after the flag u32 (divG/divOZ/maxG/ liquid units). Flags + battery percent (offset 15) are parsed - No kitchen BLE voice-language command;
ICDeviceFunctionVoiceLanguageis bit 4 and is clear on live KG2458 (0x00fc4f02). Other SKUs use body-scale sound-mode UI - “Hello Vita” ASR is on-device only; no GATT PCM/audio stream in the SDK
- FFB4 file chunks after D9 are not implemented (inline D6/D7
icononly) - Legacy protocols 110/111 (stubs only via shared models)
- BLE advertisement manufacturer data (scan matches
local_nameonly)
Development
uv run pytest
uv run mypy pyfitdaysplus
uv run ruff check .
License
MIT
Metadata
Release files for pyfitdaysplus 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pyfitdaysplus-1.0.0.tar.gz | 46.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pyfitdaysplus-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 82.4 kB
Release files / pyfitdaysplus-1.0.0.tar.gz
| Download URL | pyfitdaysplus-1.0.0.tar.gz |
|---|---|
| Size | 46.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
7841d637551c51036335d05ef73e0431f6dc163fde559bb6e9389ec4f7e1a8bf
|
|
BLAKE2b-256 checksum How to use checksums |
43a494abe575d6bdd6c8cd2502cdfb3344de63c712d2ca9c6b882ce82d636b51
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.
Transparency logRelease files / pyfitdaysplus-1.0.0-py3-none-any.whl
| Download URL | pyfitdaysplus-1.0.0-py3-none-any.whl |
|---|---|
| Size | 36.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
af6d5f1a8b9815ea2caaa1ce44b6820b82def6b6bcd4c8a740e834c7db133c10
|
|
BLAKE2b-256 checksum How to use checksums |
323acbdb47ca65ca3451ca2c1af16d268197e28a2bb3e1f050b0faffb68b5d15
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.
Transparency log