Metadata-Version: 2.4
Name: pyfitdaysplus
Version: 1.0.0
Summary: Async BLE client for ICOMON / Fitdays+ kitchen scales (protocol 113 GeneralV2).
Author-email: pantherale0 <jordan@hrvy.uk>
License-Expression: MIT
Project-URL: Bug Tracker, https://github.com/pantherale0/pyfitdaysplus/issues
Project-URL: repository, https://github.com/pantherale0/pyfitdaysplus
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Intended Audience :: Developers
Classifier: Natural Language :: English
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: System :: Hardware
Classifier: Topic :: System :: Networking
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: bleak>=3.0.2
Requires-Dist: bleak-retry-connector>=4.4
Dynamic: license-file

# 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`](https://github.com/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

```bash
pip install pyfitdaysplus
# or from a clone
uv sync
```

Requires Python 3.10+, [`bleak`](https://github.com/hbldh/bleak) and
[`bleak-retry-connector`](https://github.com/Bluetooth-Devices/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:

```python
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`:

```python
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`:

```python
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:

```python
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”) |

```bash
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`](https://github.com/Bluetooth-Devices/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).

```python
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.

```python
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

```bash
uv run pytest
uv run mypy pyfitdaysplus
uv run ruff check .
```

## License

MIT
