Skip to main content

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 bleak BLEDevice, or from a known address before the scale is in range
  • device.set_ble_device_and_advertisement_data(ble_device, advertisement) — refresh the BLE path (Home Assistant)
  • Device.connect() / disconnect() / async context manager (connects via bleak-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 caches
  • await device.async_get_weight() — cached reading, or wait for the first notify
  • device.subscribe(Event.WEIGHT, callback) — event callbacks (returns unsubscribe)
  • device.subscribe(Event.ON_DEVICE_CONFIRM, callback) — front-panel ✓ (0xAC on KG2458; one per armed D6)
  • device.subscribe(Event.HISTORY, callback) — stored 0xAC records during read_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 ✓ is Event.ON_DEVICE_CONFIRM)
  • await device.set_unit(Unit.G) (also ML, LB, OZ, …)
  • await device.read_food_selection() → FoodInfoNotify with count / foods
  • async for notify in device.food_selections(): — notify.foods is foodId + food_index
  • await 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) — armed CommonFood, or None when the session ends
  • await 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; prefer start_food_weigh)
  • await device.set_common_food_indexed(food_index, food) — cmd 215 / D7
  • await 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 — vendor DeviceFunction bits plus CompatibilityFlag (caps.flags, caps.supports(CompatibilityFlag.NUTRITION))
  • device.battery / caps.battery — percent from funInfo (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 (see docs/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; ICDeviceFunctionVoiceLanguage is 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 icon only)
  • Legacy protocols 110/111 (stubs only via shared models)
  • BLE advertisement manufacturer data (scan matches local_name only)

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)

Source distribution for pyfitdaysplus 1.0.0
File Size Uploaded
pyfitdaysplus-1.0.0.tar.gz 46.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pyfitdaysplus 1.0.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page