Skip to main content

spinev-ble

Local control for Exicom Spin EV chargers, over Bluetooth or over the charger's own OCPP connection.

No cloud, no vendor app, no account, no internet. Not affiliated with Exicom.

How it fits together

Layer What it does Dependencies
Codec (spinev_ble.protocol) Turns commands into frames and frames back into values none
Charger client (SpinEvCharger) One method per charger feature: status, start, stop, limits, settings none
Transports (spinev_ble.transports) Carry the frames to the charger per transport

The charger speaks one register protocol whatever carries it, so the client is the same everywhere and you pick the transport:

Transport Reaches the charger through Needs
BleTransport Bluetooth LE, locally or through an ESPHome Bluetooth proxy spinev-ble[bleak]
OcppTunnelTransport The charger's OCPP 1.6 connection to your central system nothing, you supply the DataTransfer call
your own Anything that implements SpinEvTransport whatever it needs

Install

pip install spinev-ble            # codec, charger client, OCPP transport; zero dependencies
pip install spinev-ble[bleak]     # adds the Bluetooth transport and the 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

To carry frames yourself instead of using a transport: over Bluetooth, write them to characteristic 49535343-1e4d-4bd9-ba61-23c647249616 with response and read replies as notifications on the same characteristic.

Charger client

SpinEvCharger takes the transport to talk through, and optionally the charger's Bluetooth password, which start and stop commands carry:

async with SpinEvCharger(transport, password=0xABCDEF) as charger:
    status = await charger.async_get_status()
    print(status.state.name, status.power_w, "W")

    await charger.async_start_charging()

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.

Transports

Bluetooth

Needs the bleak extra.

import asyncio

from bleak import BleakScanner
from spinev_ble import BleTransport, 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(BleTransport(device), password=0xABCDEF) as charger:
        print(await charger.async_get_power(), "W")


asyncio.run(main())

BleTransport takes client_class to swap the Bluetooth client for anything matching BleakClientLike. For example habluetooth.HaBleakClientWrapper reaches the charger through an ESPHome Bluetooth proxy. It is called as client_class(device, timeout=..., disconnected_callback=...). max_attempts=1 gives a fast answer when the charger's single Bluetooth slot is taken.

OCPP

A charger connected to an OCPP 1.6 central system takes the same register frames over that connection, as a DataTransfer with vendor id CPV07 and message id CC_CONFIG. OcppTunnelTransport wraps that, so every SpinEvCharger method works through your central system with no Bluetooth at all.

The transport depends on no OCPP library. Give it a function that sends one DataTransfer to the charger and returns the reply's status and data. With the ocpp package:

from ocpp.v16 import call

from spinev_ble import OcppTunnelTransport, SpinEvCharger


async def data_transfer(vendor_id: str, message_id: str, data: str):
    result = await charge_point.call(
        call.DataTransfer(vendor_id=vendor_id, message_id=message_id, data=data)
    )
    return result.status, result.data


async with SpinEvCharger(OcppTunnelTransport(data_transfer)) as charger:
    await charger.async_set_current_limit(16)
    print(await charger.async_get_current_limit())
  • The charger's JSON parser refuses whitespace between tokens, so the central system must send compact JSON.
  • The streamed history read can't travel through the tunnel and raises SpinEvUnsupportedError. The central system sees sessions as OCPP transactions instead.
  • Call notify_disconnected() on the transport when the charger's OCPP connection drops, so requests in flight fail at once.

Your own

Anything that implements SpinEvTransport can carry the frames: is_connected, async_connect(on_frame, on_disconnect), async_disconnect() and async_send(frame). The client serialises its requests, so async_send is never called concurrently.

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 (Bluetooth only) 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 Bluetooth 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 and OCPP interfaces, 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.5.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for spinev-ble 0.5.1
File Size Uploaded Provenance Actions
spinev_ble-0.5.1.tar.gz 54.6 kB With provenance Details for spinev_ble-0.5.1.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for spinev-ble 0.5.1
File Interpreter ABI Platform Actions
spinev_ble-0.5.1-py3-none-any.whl Python 3 none any Details for spinev_ble-0.5.1-py3-none-any.whl

Total release size: 97.4 kB

Release files / spinev_ble-0.5.1.tar.gz

Download URL spinev_ble-0.5.1.tar.gz
Size 54.6 kB
Tags Source
SHA-256 checksum
How to use checksums
11fb1f565aefbfc4d7c3af967be7c096dbb6656ec30115c55f7bd2165a977c77
BLAKE2b-256 checksum
How to use checksums
7e6976a7c3e6deb0f41d2d38c01e8d769bff526a0d303c9c57d1b6678269a1cb
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 Oct 24, 2026.

Transparency log

Release files / spinev_ble-0.5.1-py3-none-any.whl

Download URL spinev_ble-0.5.1-py3-none-any.whl
Size 42.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7f1da317f130bbf80f90eb9e316edcf1d49d34f7d6644055adbf13cbcfc923dd
BLAKE2b-256 checksum
How to use checksums
5b7734bb84fe4ba356bfd1272a3bbd638dbec52c4a29ab215ec85f2ecfcae954
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 Oct 24, 2026.

Transparency log

Security for spinev-ble 0.5.1

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.

Select from 2 files

Attestation for spinev_ble-0.5.1.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 Oct 24, 2026.

Transparency log

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

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page