Skip to content

About

Cellink Neo+ Dashcam Battery to Home Assistant Bridge (MQTT)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Cellink Neo+ Dashcam Battery to Home Assistant Bridge

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).

Home Assistant device page for a Cellink Neo 8+ showing sensors, configuration controls, diagnostics and recent status activity
A Neo 8+ in Home Assistant: live sensors, the controls this model supports, and status history.

📋 Features

  • 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.

🔋 Supported models

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.

🚀 Getting started

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-deps

3. Configure. Copy the example settings and fill in your WiFi and MQTT details:

cp secrets.py.example secrets.py
$EDITOR secrets.py

The 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, default 1234.
  • CELLINK_TOPIC_ID: MQTT topic and unique-ID prefix, default cellink_dashcam.
  • CELLINK_HOSTNAME: network name, default CellinkNeo (reachable as CellinkNeo.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 characters

Both secrets.py and webrepl_cfg.py are gitignored.

4. Deploy over USB:

./dev push

This 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
  1. Install Thonny: brew install --cask thonny, or download it from thonny.org.
  2. Flash MicroPython: hold BOOTSEL while 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.
  3. Install the libraries under Tools → Manage packages…: micropython-umqtt.simple, and micropython-aioble if import aioble fails in the Shell.
  4. Deploy: after step 3 above, open cellink_proto.py, main.py, secrets.py, boot.py and webrepl_cfg.py from pico_w/. Save each with File → Save as… → Raspberry Pi Pico under the same name, then press Ctrl-D in the Shell to restart.

🔄 Updating

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 your CELLINK_HOSTNAME). If CellinkNeo.local doesn't resolve, look it up in your router's client list (its MAC starts 28:cd:c1, Raspberry Pi's prefix) and use the IP address.
  • Settings and hostname: ./dev ota doesn't copy secrets.py or boot.py. To change a setting or the hostname, edit it and redeploy over USB with ./dev push.
  • Check before pushing: a main.py that 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 with micropython -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.py

To 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.bak

WebREPL 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.

🏠 Home Assistant entities

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.

Controls

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.

🩺 Troubleshooting

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 set CELLINK_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. -1 is a generic failure, -2 means the network wasn't found.
  • MQTT ECONNABORTED: the broker refused the connection. Check MQTT_USER / MQTT_PASS, and that MQTT_HOST resolves 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; reconnecting means 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 JSON from mqtt.discovery.
  • Settings unlock failed: the battery didn't accept the PIN. Check CELLINK_PASSWORD (4 digits; 1234 unless changed in the app). None means 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.

⚠️ Known issues

  • aioble internals are patched. main.py replaces the notify characteristic's private _notify_queue and _on_notify_indicate to 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 @@G frame, which has never been seen on a real battery.
  • No real-time clock. Log timestamps count from boot (00:00:12 etc.), not wall-clock time.

🔬 Protocol and tests

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_proto

🧠 Lessons learned

Pitfalls 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 0D terminator 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.py reassembles 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.simple sizes 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.

🤖 AI disclosure

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.

📄 Licence

Released under the MIT Licence. This is an unofficial project, not affiliated with or endorsed by Cellink or its manufacturer.

About

Cellink Neo+ Dashcam Battery to Home Assistant Bridge (MQTT)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages