spinev-ble
Local Bluetooth LE control for Exicom Spin EV chargers.
The core is a dependency free codec. It turns commands into bytes and bytes back into values, and never touches a radio. How those bytes reach the charger is your choice: bleak, an ESPHome Bluetooth proxy, a serial bridge, or nothing at all if you only want to inspect frames.
No cloud, no vendor app, no account, no internet. Not affiliated with Exicom.
Install
pip install spinev-ble # codec only, zero dependencies
pip install spinev-ble[bleak] # adds the optional BLE client and CLI
Python 3.11 or newer.
Just the protocol
Nothing here needs a Bluetooth stack. Build a frame, send it however you like, decode what comes back.
from spinev_ble import Command, Register, build_control, build_read
from spinev_ble import parse_frame, decode_float, decode_uint, ChargerState
build_control(Command.START, password=0xABCDEF).hex() # '10ac3c0101abcdef'
build_control(Command.STOP, password=0xABCDEF).hex() # '10ac3c0110abcdef'
build_read(Register.POWER).hex() # '10ac840000000000'
reply = parse_frame(bytes.fromhex("10ac840045713d71"))
decode_float(reply.raw) # 3859.84 watts
state = decode_uint(bytes.fromhex("00000004"))
ChargerState(state) # ChargerState.CHARGING
Write the bytes to characteristic 49535343-1e4d-4bd9-ba61-23c647249616 with response, and read replies as notifications on that same characteristic. That is the whole transport contract.
Optional BLE client
Needs the bleak extra. Convenience only, the codec above is the real library.
import asyncio
from bleak import BleakScanner
from spinev_ble import SpinEvCharger
async def main() -> None:
device = await BleakScanner.find_device_by_address("AA:BB:CC:DD:EE:FF")
if device is None:
raise SystemExit("charger not found, is the phone app connected to it?")
async with SpinEvCharger(device, password=0xABCDEF) as charger:
status = await charger.async_get_status()
print(status.state.name, status.power_w, "W")
await charger.async_start_charging()
await asyncio.sleep(3)
await charger.async_stop_charging()
asyncio.run(main())
ChargerStatus is an immutable snapshot. Every reading is None if the charger did not supply it, so check before formatting. When the charger reports a state this library does not name, status.state is None and status.state_value holds the raw number, rather than the whole read failing.
Register reads are logged as an id and length, never the decoded value, since some registers hold credentials.
Custom transports
SpinEvCharger does not care what carries the bytes. Pass client_class to swap the transport for anything matching BleakClientLike, which is the handful of members the client actually uses:
from spinev_ble import SpinEvCharger
charger = SpinEvCharger(device, password, client_class=MyTransport)
It is called as client_class(device, timeout=..., disconnected_callback=...).
Command line
Installed with the bleak extra.
spinev-ble scan # find chargers nearby
spinev-ble password # ask the charger for its own password
spinev-ble status # live telemetry
spinev-ble history # stored charging sessions
spinev-ble start # start charging
spinev-ble stop # stop charging
Or python -m spinev_ble ... if you prefer.
Pass the password through the environment rather than --password, so it stays out of your shell history and out of the process list:
export SPINEV_BLE_PASSWORD=0xABCDEF
spinev-ble start
Your charger's Bluetooth password
Start and stop commands carry a per charger password. Reads do not need it, so telemetry works without one.
Ask the charger for it:
spinev-ble password
That reads register 0x32 on your own charger.
Treat it as a credential: do not commit it, and do not paste it into an issue.
What is supported
| Feature | Register | API |
|---|---|---|
| Start and stop charging | 0x3C |
async_start_charging, async_stop_charging |
| Charger state | 0x67 |
async_get_state, async_get_state_value |
| Active power, voltage, current | 0x84, 0x0A, 0x14 |
async_get_power, async_get_voltage, async_get_current |
| Session energy and duration | 0x35, 0x59 |
async_get_status |
| Lifetime energy and duration | 0x65, 0x6A |
async_get_status |
| Charging history | 0x68 |
async_get_history |
| Firmware version | 0x52 |
async_get_status |
| Active alarms | 0x39 |
async_get_alarms, async_get_alarm_defs |
| Charging current limit | 0x4F |
async_get_current_limit, async_set_current_limit |
| Charger password | 0x32 |
async_get_password |
| WiFi settings | 0x61, 0x63 |
async_get_wifi_ssid, async_set_wifi |
| OCPP settings | 0x5E, 0x60, 0x62, 0x64 |
async_get_ocpp_config, async_set_ocpp_config |
Alarms
The charger keeps its alarms in two 32 bit words on register 0x39. Both are read: the first like any register, the second with ALARM_BANK2_FLAG in the flag byte. async_get_alarms returns the names from both, and async_get_alarm_defs returns AlarmDef entries carrying the charger's own fault code, severity and firmware constant as well.
for alarm in await charger.async_get_alarm_defs():
print(alarm.name, alarm.code, alarm.severity) # L1 Overcurrent 302 Critical
severity is an AlarmSeverity (Critical, Major, Minor, Warning) or None where the charger assigns none. It is a StrEnum, so it compares equal to its label.
The second word holds the three phase and peripheral faults; it reads all clear on a single phase unit. The same bit means different things in each word, so a word must be decoded against its own bank:
from spinev_ble import ALARMS, build_alarm_read, decode_alarms
build_alarm_read(2).hex() # '10ac391000000000'
decode_alarms(1 << 15, bank=1) # ['SPD Fail']
decode_alarms(1 << 15, bank=2) # ['Unexpected CP Voltage']
ALARMS is the whole table, both banks. Passing any bank other than 1 or 2 raises SpinEvProtocolError.
A charger whose firmware does not answer the bank 2 read is treated as reporting no bank 2 alarms, so bank 1 still reaches the caller.
Bank 1 bits 10 and 11 are separate faults, the LCD board and the LED board. Bits 18 to 24 are unused, and bits 25 to 31 are configuration change flags rather than faults, so neither range is decoded.
Things worth knowing
Writing network settings can strand the charger. async_set_wifi and async_set_ocpp_config change how the charger reaches the outside world. A wrong value leaves it unable to connect until it is re-provisioned. Read the values back afterwards, and keep the phone app available to restore the originals.
Current limits are model specific. async_set_current_limit guards against anything below 6 A or above 32 A. 32 A is the ceiling of the top single phase unit; three phase and lower rated models differ, so pass max_amps to match yours and read the limit back to confirm what the charger accepted.
One connection at a time. These chargers accept a single Bluetooth client. While the phone app is connected you cannot connect, and vice versa. Close the app fully, not just to the background.
Commands are not instant. The charger acknowledges immediately, updates its state register after about a second, and starts delivering power about two seconds later. Do not treat a missing state change in the first second as a failure.
History timestamps are not charging duration. A record's end timestamp lines up with the next record's start, so it marks unplug time rather than the moment charging stopped. Sessions can span days while only drawing power for part of that. Do not compute average power from energy divided by duration.
History is a rolling window. The charger keeps only recent sessions, not everything.
Do not poll hard. Once every thirty seconds is plenty for monitoring, and five seconds is enough for a live power graph.
Development
uv sync
uv run pytest
uv run prek run --all-files
uv run pylint src/spinev_ble
Install the git hook so prek runs automatically on commit:
uv run prek install
Disclaimer
Independent, unofficial project providing Python API bindings for local access. Not affiliated with, endorsed by, or supported by Exicom.
Provided "as is", without warranty of any kind, express or implied, as stated in the Licence below. Use of this library, and of your charger's Bluetooth interface, is at your own risk and subject to your charger's own terms of use and warranty.
Licence
MIT
Metadata
Release files for spinev-ble 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| spinev_ble-0.4.0.tar.gz | 42.2 kB | With provenance | Details for spinev_ble-0.4.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| spinev_ble-0.4.0-py3-none-any.whl | Python 3 | none | any | Details for spinev_ble-0.4.0-py3-none-any.whl |
Total release size: 75.9 kB
Release files / spinev_ble-0.4.0.tar.gz
| Download URL | spinev_ble-0.4.0.tar.gz |
|---|---|
| Size | 42.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1312a23375a1f7cbca2992ba07ad327534ff9e8ac20e80b461852b13fc903085
|
|
BLAKE2b-256 checksum How to use checksums |
74ace9ba79fb75ccef5890a464fdb584f7c7ca883881b5a637b4e1d5ec24a6dd
|
| 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 19, 2026.
Transparency logRelease files / spinev_ble-0.4.0-py3-none-any.whl
| Download URL | spinev_ble-0.4.0-py3-none-any.whl |
|---|---|
| Size | 33.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
55fc837bec752eba6703c66ecc9a48df33122a42bffd40bff9e9fbdbc068f8a4
|
|
BLAKE2b-256 checksum How to use checksums |
18793562c9017528841b5ed42aa42c76524ed949f718eecbc141dd0d3e2ef68c
|
| 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 19, 2026.
Transparency logSecurity for spinev-ble 0.4.0
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.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for spinev_ble-0.4.0.tar.gz
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 19, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.