A Raspberry Pi Pico W that talks to a Cellink Neo+ family dashcam battery over Bluetooth Low Energy and publishes its telemetry and settings to Home Assistant via MQTT. Built and tested on a Cellink Neo 8+; other models from the same app should work (see Supported models).

A Neo 8+ in Home Assistant: live sensors, the controls this model supports, and status history.
- Auto-Discovery: everything appears in Home Assistant as a single device, named after the battery's model (e.g. "Cellink Neo 8+").
- Telemetry: SoC, input/output/battery voltage, charge and discharge current, temperature, time to full, usable time, remaining capacity (mAh), status (Charging / Discharging / Fully Charged / Car Battery Mode…), firmware, and low-voltage / high-voltage / high-temperature warnings.
- Controls: whichever of these the battery supports and hasn't locked: charge current limit, output voltage, parking mode, E-charging, R-charging, car battery mode, car voltage (12/24 V), battery select (L/M/H), beep and charging stop. They're written with the same unlock → set → save sequence as the official app.
- Auto-detect: scans for any supported model, or pin an address in
secrets.py. - Robust framing: BLE fragments are reassembled using the frame's length byte, so a lost fragment discards one reading instead of publishing garbage.
- Self-healing: reconnects BLE if readings stop for 60 s; OTA updates over WiFi.
Everything the official CELLINK Neo Plus app connects to: names starting CNEO+, NEO+, NEO5+, NEO6+, NEO8+, NEO9+, NEO17+, NEO21+, N200+, N8PS, N10PS, N10PSE, N12PS, N80PS, N80PSE, N100PS, N100PE, PWC8, CAMP and EGENBT. The app has no per-model code; each battery reports which settings it has, and the bridge creates controls to match.
| Model | Status |
|---|---|
Neo 8+ (N8PS) |
Tested daily. Controls: charge current limit, beep, charging stop |
Batteries ≥ 65 Ah (e.g. N80PS, N100PS) |
Use an extra extended-status frame; implemented from the app but untested |
| Everything else listed above | Should work; untested |
The older CELLINK Neo app's products (names starting CN, not CNEO+) use a different protocol and aren't supported.
ESP32 boards don't work: their Bluetooth radios can't see the battery's Silicon Labs chip at all, with either Bluedroid or NimBLE. The Pico W's CYW43439 radio works fine.
You need:
- A Raspberry Pi Pico W (or Pico WH), a USB cable to plug into the battery pack - or close by.
- Home Assistant with an MQTT broker (e.g. the Mosquitto add-on) and the MQTT integration, with discovery on (the default).
- A Mac or Linux machine with Python 3 and mpremote:
brew install mpremote
1. Flash MicroPython. Hold the Pico's BOOTSEL button while plugging it in, so it mounts as RPI-RP2. Download the Pico W firmware (.uf2) from micropython.org/download/RPI_PICO_W and copy it onto that drive. The Pico reboots into MicroPython.
2. Install the libraries:
cd pico_w
./dev install-deps3. Configure. Copy the example settings and fill in your WiFi and MQTT details:
cp secrets.py.example secrets.py
$EDITOR secrets.pyThe battery settings in that file are optional:
CELLINK_ADDR: pin the battery's address. Without it, the bridge scans for 15 s and uses the first supported model it sees. Set it if more than one battery could be in range.CELLINK_PASSWORD: the settings PIN, default1234.CELLINK_TOPIC_ID: MQTT topic and unique-ID prefix, defaultcellink_dashcam.CELLINK_HOSTNAME: network name, defaultCellinkNeo(reachable asCellinkNeo.local).CELLINK_NAME: override the HA device name.
Running more than one bridge? Give each its own CELLINK_TOPIC_ID and CELLINK_HOSTNAME. Otherwise their MQTT entities collide, and .local lookups can reach either Pico.
Then set a WebREPL password, used for updates over WiFi:
cp webrepl_cfg.py.example webrepl_cfg.py
$EDITOR webrepl_cfg.py # 4–9 charactersBoth secrets.py and webrepl_cfg.py are gitignored.
4. Deploy over USB:
./dev pushThis copies cellink_proto.py, main.py, secrets.py, boot.py and webrepl_cfg.py to the Pico, then resets it. Watch it start with ./dev tail (Ctrl-X to exit). You should see it find the battery and start logging readings every 10 s; see the expected boot log.
5. Check Home Assistant. A device named after your battery (e.g. Cellink Neo 8+) appears under Settings → Devices & services → MQTT, with its sensors and whichever controls your battery supports.
6. Install it. Unplug the Pico and power it in the car within Bluetooth range of the battery. It starts on its own and needs WiFi in range to reach Home Assistant.
Important
Make sure the phone app isn't connected to the battery, since only one device can connect at a time.
Using Thonny instead of mpremote
- Install Thonny:
brew install --cask thonny, or download it from thonny.org. - Flash MicroPython: hold
BOOTSELwhile plugging the Pico in, then in Thonny choose the interpreter selector (bottom right) → Configure interpreter… → MicroPython (Raspberry Pi Pico) → Install or update MicroPython → variant Raspberry Pi Pico W / Pico WH → Install. - Install the libraries under Tools → Manage packages…:
micropython-umqtt.simple, andmicropython-aiobleifimport aioblefails in the Shell. - Deploy: after step 3 above, open
cellink_proto.py,main.py,secrets.py,boot.pyandwebrepl_cfg.pyfrompico_w/. Save each with File → Save as… → Raspberry Pi Pico under the same name, then pressCtrl-Din the Shell to restart.
Pull the latest code, run the tests, then push over WiFi. There's no need to unplug the Pico from the car:
git pull
cd pico_w
python3 -m unittest test_cellink_proto
./dev ota CellinkNeo.local <webrepl-password>./dev ota uploads cellink_proto.py and main.py, then writes a reboot flag file. The bridge checks for it every 5 s and restarts itself. No USB connection or mpremote is needed.
- Finding the Pico: it registers on the network as
CellinkNeo(or yourCELLINK_HOSTNAME). IfCellinkNeo.localdoesn't resolve, look it up in your router's client list (its MAC starts28:cd:c1, Raspberry Pi's prefix) and use the IP address. - Settings and hostname:
./dev otadoesn't copysecrets.pyorboot.py. To change a setting or the hostname, edit it and redeploy over USB with./dev push. - Check before pushing: a
main.pythat fails to start leaves WebREPL running, so you can push a fix. One that hangs can block WebREPL, and then you need USB. Compile-check first withmicropython -c "compile(open('main.py').read(),'main.py','exec')"(brew install micropython).
To roll back, check out the previous version and push it the same way (from the repo root):
git checkout <previous-commit> -- pico_w/main.py pico_w/cellink_proto.py
pico_w/dev ota CellinkNeo.local <webrepl-password>
git checkout HEAD -- pico_w/main.py pico_w/cellink_proto.pyTo fetch the Pico's log or back up its files over WiFi, use the WebREPL client that ./dev ota downloads. Give it the IPv4 address: CellinkNeo.local resolves to an IPv6 address first, which the client can't use.
python3 .webrepl_cli.py -p <webrepl-password> <pico-ip>:/log.txt log.txt
python3 .webrepl_cli.py -p <webrepl-password> <pico-ip>:/main.py main.py.bakWebREPL is started by boot.py and is plain, unencrypted websocket: fine on a trusted LAN, but don't expose it to the internet. Don't run MicroPython's own webrepl_setup: it replaces boot.py with one that doesn't bring WiFi up.
Everything appears under one device via MQTT discovery. State is published to tele/cellink_dashcam/state and log lines to tele/cellink_dashcam/log (the cellink_dashcam part is CELLINK_TOPIC_ID).
| Type | Entities |
|---|---|
| Sensors | SoC, Input / Output / Battery Voltage, Charge / Discharge Current, Temperature, Time to Full, Usable Time, Remaining Capacity (mAh), Status |
| Diagnostic sensors | Total Capacity, Ext Battery Capacity, Output Voltage Setting, Firmware |
| Binary sensors | Low Voltage, High Voltage and High Temperature warnings |
| Controls | Only those the battery supports; see below |
Discharge current reads 0 at small standby loads. Remaining Capacity comes from the battery's own charge counter, so it tracks slow standby drain that the current sensors miss.
Each battery reports which settings it has and which are locked (capability bits). The bridge creates controls only for settings that are supported and unlocked, and removes any others from HA. They appear under Configuration on the device:
| Control | Type | Command topic suffix |
|---|---|---|
| Charge Current Limit | number, battery's own min–max, 0.5 A steps | chg_limit |
| Output Voltage | number, battery's own min–max, 1 V steps | out_v_set |
| Parking Mode | switch | psm |
| E-Charging / R-Charging | switch | e_charging / r_charging |
| Car Battery Mode | switch | main_battery |
| Car Voltage | select: 12V / 24V | car_volt |
| Battery Select | select: L / M / H | batt_select |
| Beep | switch | beep |
| Charging Stop | switch | chg_stop |
On the Neo 8+ that's Charge Current Limit, Beep and Charging Stop.
Each change unlocks settings with CELLINK_PASSWORD (default 1234), writes the value, then saves, the same as the app does. Commands go to cmd/cellink_dashcam/<suffix>/set. Values outside the battery's reported range are refused, as are commands for controls it doesn't have. The result is logged (Setting 0x37 = 7500 -> OK), and the bridge polls straight away so HA shows the new value.
The app's E-Charging, R-Charging and L/M/H labels come without descriptions, so check your battery's manual before changing them. Battery capacity settings aren't exposed: the app checks them against limits it downloads from the manufacturer.
A healthy start looks like this (./dev tail over USB, or log.txt over WiFi):
WiFi: 192.168.x.x
MQTT: connected to homeassistant.local
Entering BLE supervisor loop
ble_session: entering
Scanning 15s for a Cellink battery...
Found N8PS 112233 at Device(ADDR_PUBLIC, aa:bb:cc:11:22:33)
Connecting to BLE Device(ADDR_PUBLIC, aa:bb:cc:11:22:33)
BLE connected; discovering service
Battery model: N8PS
polling every 10s
MQTT discovery sent for Cellink Neo 8+ (N8PS); controls: chg_limit_a, beep, chg_stop
SoC: 87% | In: 13.2V | Chg: 0.5A | Dis: 0.0A | Charging | rejected: 0
With CELLINK_ADDR set, the two scanning lines are skipped. Discovery is sent once the first reading arrives, because that reading says which controls the battery has. rejected counts frames discarded because a BLE fragment went missing; it should stay near zero.
no Cellink battery found: nothing with a supported name was advertising during the scan. Check that the battery is powered and not connected to the phone app, or setCELLINK_ADDR.- BLE connect timeout, or retrying forever: check the battery is powered and nothing else is connected to it (phone app, another bridge). A phone BLE scanner such as nRF Connect shows whether it's advertising.
ImportError: no module named 'aioble'or'umqtt': run./dev install-deps.- WiFi fails (
status=-3): wrong password.-1is a generic failure,-2means the network wasn't found. - MQTT
ECONNABORTED: the broker refused the connection. CheckMQTT_USER/MQTT_PASS, and thatMQTT_HOSTresolves from the Pico's network (try the broker's IP). - HA stops updating but the Pico is on the network: fetch
log.txt.no status for 60s; reconnectingmeans the watchdog caught a stall and recovered. If the log simply stops, push a fix or power-cycle the Pico. - A sensor never appears in HA: look under Settings → System → Logs for
Unable to parse JSONfrommqtt.discovery. Settings unlock failed: the battery didn't accept the PIN. CheckCELLINK_PASSWORD(4 digits;1234unless changed in the app).Nonemeans no reply at all, which points at a BLE problem rather than a wrong PIN.Ignoring command: … isn't supported by this battery: HA sent a command for a control this battery doesn't have or has locked. Usually a stale entity; it disappears once discovery is resent.
- aioble internals are patched.
main.pyreplaces the notify characteristic's private_notify_queueand_on_notify_indicateto stop fragments being dropped and to avoid a stall (see Lessons learned). Re-check both after upgrading aioble. - Occasional late readings. About one reading in four arrives roughly 10 s late, together with the next one. Nothing is lost. It may be the CYW43 radio holding incoming data until the next poll is sent; not yet confirmed.
- Only the Neo 8+ is tested. Other models follow the same app code, but may have firmware quirks the app tolerates silently. Large batteries (≥ 65 Ah) rely on the extended
@@Gframe, which has never been seen on a real battery. - No real-time clock. Log timestamps count from boot (
00:00:12etc.), not wall-clock time.
The full frame layout, capability bits, command set and settings IDs are in PROTOCOL.md. They were decoded from the official CELLINK Neo Plus Android app (v1.4.21, kr.co.egens.cellinkneoplus) and checked against a Bluetooth HCI capture of the app talking to a Neo 8+.
The parser, pico_w/cellink_proto.py, is plain Python, so its tests run on your computer:
cd pico_w
python3 -m unittest test_cellink_protoPitfalls that cause wrong readings or a silent bridge:
- Spliced frames: the battery sends each 75-byte status frame as 4–5 notifications under 1 ms apart. aioble's default one-slot notify queue drops some. A parser that scans for the
0A 0Dterminator and accepts anything ≥ 75 bytes then glues the head of one frame to the next. Symptoms: discharge current jumping to 2–63 A (always a multiple of 0.256), capacity reading 8000 (actually the charge-current setting), and nonsense time-to-full values.pico_w/cellink_proto.pyreassembles on the length byte instead. - aioble notify stall: with a longer aioble queue, a fragment can be stranded with its wake-up event cleared, freezing the bridge while BLE stays connected. The bridge signals on every notification, and a 60 s watchdog reconnects if readings stop for any reason.
- Truncated MQTT messages:
umqtt.simplesizes packets by character count, not bytes, so any non-ASCII text (like°C) truncates the message and HA silently rejects it. Encode payloads to bytes before publishing. - Field widths: temperature is a signed 16-bit value and capacities are 32-bit. Reading them as one and two bytes breaks below 0 °C and above 65 Ah.
This project was built with substantial help from AI. Anthropic's Claude, used through Claude Code, wrote much of the code, tests and documentation. It also did the protocol reverse-engineering: decompiling the official Android app, decoding a Bluetooth capture, and diagnosing the bugs listed under Lessons learned. The maintainer directed the work, deployed it, and tested it on a real Cellink Neo 8+ in Home Assistant.
As with any unofficial reverse-engineered tool, review the code before relying on it, especially the controls, which change settings on the battery itself. Models other than the Neo 8+ are supported on the strength of the app's code alone and haven't been tested.
Released under the MIT Licence. This is an unofficial project, not affiliated with or endorsed by Cellink or its manufacturer.