asf02
Lulumiao pet slow feeders can only be managed via an App that has since been delisted from the Google Play store. Without the App, owners cannot update feed schedules or synchronize the feeder clock which is prone to drift. This library allows you to once again manage your slow feeder and save it from the landfill.
Python client library for managing Lulumiao pet slow feeders over BLE. The protocol was reverse-engineered from the vendor Android app and verified to work with devices having v1.19.12 firmware.
The feeder speaks a small JSON-RPC dialect over two GATT characteristics.
This library wraps the whole conversation — discovery, pairing, unlock,
schedule management, on-demand feeding, and status — behind a tiny async
API. Built on bleak; no other runtime
dependencies.
Product Details
| Brand | Lulumiao |
| Device | Pet slow feeder |
| Manufacturer | Hong Kong M Spacer Limited |
| Model | CWH01L-A1 (OEM model ASF02, per info and BLE advertising) |
| FCC ID | 2A9QN-CWH01L-A1 |
Product Images
Install
From a local repo checkout
pip install -e ".[dev]" # includes pytest
From GitHub
pip install git+https://github.com/cptskippy/asf02.git
From PyPI
pip install asf02
Quick start
import asyncio
from asf02 import ASF02Client
async def main():
async with ASF02Client("<DEVICE_MAC>", token="<BIND_TOKEN>") as feeder:
await feeder.unlock() # required on every connect
await feeder.sync_time(-25200) # PDT w/DST in seconds
info = await feeder.info() # {'f': '1.19.12', 'm': 'ASF02', ...}
status = await feeder.status() # {'n': 1065, 'b': 458, ...}
await feeder.feed(1) # dispense 1 portion now
await feeder.set_schedule([(15, 30, 1), (16, 0, 1)])
asyncio.run(main())
Discovery
from asf02 import discover_feeders
async def main():
for f in await discover_feeders(timeout=10):
print(f.address, f.model, f.rssi)
Feeders advertise continuously: local name ASF02-<last6 of MAC> plus
manufacturer data (company 0xFFFF) starting 02 03, with the MAC embedded.
Pairing / tokens
- Generate a token:
ASF02Client.new_token()(10 chars, same charset as the vendor app). - Keep the token with your device config. The unlock code is always derived
from it (
md5("0000:"+token)— seeunlock_code_for). - Store it in the device:
await feeder.bind(token). Re-binding overwrites the previous token — the old one stops working.
Schedules
- The device stores a fixed 10-slot array;
set_schedulereplaces it. - Entries are
(hour, minute, portion)or(hour, minute, portion, enabled). set_schedule([])(orclear=True) wipes the schedule.- Time fields are hex on the wire — the library handles this, but know it:
0012000200is 18:00, not 12:00. - The write response is the authoritative confirmation (some firmware builds answer schedule reads with stale/empty data).
Behavior notes (learned from the hardware)
- The device locks across every (re)connect — call
unlock()each time (or useconnect_unlocked()). Onlyinfoworks while locked. time.calibrationtakes epoch seconds + tz offset in seconds (incl. DST; e.g. −25200 for PDT — same convention the vendor app uses). Always re-sync on connect; schedules run on the device clock.feeder.logmay return no notification at all —get_log()returnsNonein that case instead of erroring.- No MTU negotiation needed for any documented command (the link carries the full 79-byte responses fine).
- Transient connections are fine: connect → work → disconnect. The feeders run autonomously between visits.
- The feeder clock drifts over time and freezes entirely while unpowered.
Sync it (
sync_time()) semi-regularly — and on every reconnect if you want schedules to stay on time — since the schedule runs on the device clock, not yours.
Home Assistant
The library builds its own bleak.BleakClient by default. To run inside
Home Assistant the connection must go through HA's managed Bluetooth
stack (connection slots, adapter/ESPHome-proxy routing), so pass a
client_factory that returns HA's wrapper instead:
from homeassistant.components import bluetooth
ble_device = bluetooth.async_ble_device_from_address(
hass, address, connectable=True)
async with ASF02Client(address, token,
client_factory=lambda addr: ble_device) as feeder:
...
The factory may return a pre-constructed client; only its
connect/disconnect/start_notify/write_gatt_char methods are used.
Without the argument the library behaves exactly as before (plain bleak),
so standalone use and the CLI example are unaffected.
API Status
| Method | Status |
|---|---|
info |
✅ Working |
bind |
✅ Working |
unlock |
✅ Working |
time.calibration |
✅ Working |
feeder.status |
✅ Working |
feeder.feed |
✅ Working |
feeder.stop |
✅ Working |
feeder.plan (read) |
⚠️ No result on v1.19.12 firmware |
feeder.plan (write) |
✅ Working |
feeder.log |
⚠️ No result on v1.19.12 firmware |
IMPORTANT NOTE: While feeder.plan (read) and feeder.log are defined, the
v1.19.12 firmware appears to have a bug and it will always return null. It
is advised to retain a copy of any plan sent the feeder as you may not be
able to retrieve it in the future.
Project layout
asf02/
protocol.py pure codecs + constants (unit-testable, no I/O)
client.py async device client (bleak)
discovery.py scanner
examples/cli.py demo CLI (run on a machine with Bluetooth)
tests/ unit tests anchored to real captured wire traffic
Publishing
See CONTRIBUTING.md for how releases are cut and how PyPI publishing is configured.
License
Apache-2.0
Metadata
Release files for asf02 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| asf02-0.2.1.tar.gz | 24.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| asf02-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 43.7 kB
Release files / asf02-0.2.1.tar.gz
| Download URL | asf02-0.2.1.tar.gz |
|---|---|
| Size | 24.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9ee8aeb99e8a1184800d9b124b397b3aa540f43915aee301f5ce31188c1e38bd
|
|
BLAKE2b-256 checksum How to use checksums |
dd59f4a51e5308fc488609250be9df10faded05e7a3fbd97c989fd7ad9bb5ac3
|
| 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 30, 2026.
Transparency logRelease files / asf02-0.2.1-py3-none-any.whl
| Download URL | asf02-0.2.1-py3-none-any.whl |
|---|---|
| Size | 19.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
626eb448a19e903f81e4f255edff270d00be6a7c94b975307b27eeb237a29ec0
|
|
BLAKE2b-256 checksum How to use checksums |
6a258da53ab307577fbca05caf5f3fe082870060ff4da31157d5da9cbbe98cf9
|
| 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 30, 2026.
Transparency log