Skip to main content

easee-ble

Local Bluetooth control for Easee EV chargers. Talk to the charger directly with the PIN printed on the unit.

pip install easee-ble

Requires Python 3.11+. For Home Assistant, use ha-easee-ble, which is built on this.

Quick start

import asyncio
from bleak import BleakScanner
from easee_ble import EaseeCharger, PhaseMode

async def main():
    device = await BleakScanner.find_device_by_address("AA:BB:CC:DD:EE:FF")
    charger = EaseeCharger(device, pin="1234", serial="EMX00000")

    await charger.connect()                # handshake; the link is held open
    print(await charger.poll())            # {'chargerOpMode': 3, 'totalPower': 2.69, ...}

    await charger.perform(lambda s: s.set_charger_enabled(True))
    await charger.perform(lambda s: s.set_phase_mode(PhaseMode.AUTO))

    await charger.disconnect()

asyncio.run(main())

You need two things from the charger: the PIN printed on the unit, and its serial (EMXXXXXX, also on the unit and in the Easee app).

Finding your charger

Scan by service UUID if you do not know the address:

from easee_ble import SERVICE_UUID

device = await BleakScanner.find_device_by_filter(
    lambda d, adv: SERVICE_UUID in adv.service_uuids
)

Chargers usually advertise only intermittently. To get one to show up, either set Bluetooth to always-on in the Easee app, or open a window with a long press of the charger's touch button.

Reading

poll() merges State and Config into one dict of named fields:

data = await charger.poll()
data["chargerOpMode"]        # 3
data["totalPower"]           # 2.69
data["maxChargerCurrent"]    # 16
charger.unknown              # fields we have no name for yet, by number

Two fields belong together. chargerOpMode describes the car and cable - it still reads AWAITING_START for a charger that has been switched off. reasonForNoCurrent says why no current flows, and reads 53 (charger disabled) in exactly that case. Show both:

from easee_ble import charger_op_mode, reason_for_no_current

charger_op_mode(data["chargerOpMode"])                # ChargerOpMode.CHARGING
reason_for_no_current(data.get("reasonForNoCurrent")) # 'Charger disabled'

Both return None on a value this library has not seen, where ChargerOpMode() and NetworkStatus() raise. The descriptions are display text; key on reason_for_no_current_slug() if you need something stable.

Fields at their default value are absent from the wire, not zero. poll() already fills a curated set back in as 0, so treat a missing field as unknown rather than surprising.

Commands

Every command goes through perform(), which builds the request and checks the answer:

await charger.perform(lambda s: s.set_charger_enabled(True))
await charger.perform(lambda s: s.set_max_charger_current(16))
await charger.perform(lambda s: s.set_dynamic_charger_current(10))
await charger.perform(lambda s: s.set_circuit_rated_current(16))        # the fuse; or (p1, p2, p3)
await charger.perform(lambda s: s.set_max_circuit_current(25))
await charger.perform(lambda s: s.set_phase_mode(PhaseMode.LOCKED_3_PHASE))
await charger.perform(lambda s: s.set_led_brightness(75))               # 0-100
await charger.perform(lambda s: s.set_cable_locked(True))
await charger.perform(lambda s: s.pause_charging())
await charger.perform(lambda s: s.resume_charging(16))

PhaseMode is LOCKED_1_PHASE, AUTO or LOCKED_3_PHASE.

RFID keys

from easee_ble import command_payload

reply = await charger.perform(lambda s: s.list_local_rfids())
names = command_payload(reply)["utns"]
await charger.perform(lambda s: s.add_local_rfid("Alice", "04a1b2c3d4e5f6"))  # the tag's UID
await charger.perform(lambda s: s.remove_local_rfid("04a1b2c3d4e5f6"))
await charger.perform(lambda s: s.set_local_authorization(True))   # charging needs a key

Pairing mode (set_rfid_pairing_mode()) hands a scanned tag to your Easee account in the cloud, not to the charger's own list.

WiFi

from easee_ble import wifi_networks

reply = await charger.perform(lambda s: s.scan_wifi())
wifi_networks(reply)                  # [{'ssid': 'Home', 'rssi': -64}, ...]
await charger.perform(lambda s: s.set_wifi("Home", "passphrase"))

Other reads

Besides poll(), individual frames are available through perform():

await charger.perform(lambda s: s.poll_state())
await charger.perform(lambda s: s.poll_config())
await charger.perform(lambda s: s.poll_structure())
await charger.perform(lambda s: s.poll_debug())

A reply only arrives on a subscribed channel, and the last two are not subscribed to by default. Ask for them at connect - EaseeCharger(..., channels=DEFAULT_CHANNELS | {Channel.DEBUG}) - or the call raises. poll() takes the same argument to read them alongside Config and State.

Errors

from easee_ble import EaseeCommandRefused, EaseeConnectionError, JPakeError

try:
    await charger.connect()
    await charger.perform(lambda s: s.set_max_charger_current(32))
except JPakeError:            # almost always the wrong PIN
    ...
except EaseeCommandRefused:   # the charger answered, and said no
    ...
except EaseeConnectionError:  # connecting or talking to it failed
    ...

A reply is not an acknowledgement - the charger answers a command it refuses just as promptly as one it accepts. perform() checks for you and raises EaseeCommandRefused; anything else, command_accepted() and command_refusal() check by hand.

Connection notes

  • The connection is long-lived: connect once and keep polling. Connecting is the expensive, failure-prone part.
  • Not safe for concurrent use. One request may be in flight at a time - serialise with a lock if several tasks share a charger.
  • Pass on_disconnect= to be told the moment the link drops rather than at your next poll; check charger.connected before using it.
  • The charger has a single connection slot. Always disconnect() when done.
charger = EaseeCharger(device, pin="1234", serial="EMX00000",
                       on_disconnect=lambda c: print("link lost"))

Without bleak

Session is sans-io: it builds requests and parses replies and does no I/O, so you can drive it over any transport.

from easee_ble import Session

s = Session(pin="1234", serial="EMX00000")
req = s.start_handshake()          # write req.data to req.channel, feed the reply back
req = s.read_round_one(reply)
s.read_round_two(reply)            # s.established is now True
frame = s.parse(req.channel, reply)

Development

pip install -e ".[dev]"
pytest

Tests run against captured bytes from a real charger, so they need no hardware.

Notes

Unofficial. Reverse-engineered. Not affiliated with or endorsed by Easee. No warranty, changing charger settings is at your own risk. Barely tested.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

easee_ble-1.0.0.tar.gz (50.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

easee_ble-1.0.0-py3-none-any.whl (33.8 kB view details)

Uploaded Python 3

File details

Details for the file easee_ble-1.0.0.tar.gz.

File metadata

  • Download URL: easee_ble-1.0.0.tar.gz
  • Upload date:
  • Size: 50.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for easee_ble-1.0.0.tar.gz
Algorithm Hash digest
SHA256 79906b3a8dae8e23cf3469d83b15ce81a7b64d9c7c79cb3e0ed0fe667983ebad
MD5 c228f250040441e30e1a57586b98d35c
BLAKE2b-256 f9411aed08177efeb946dcfe3478476a03d6c03e5af6089b9420f06267806f8e

See more details on using hashes here.

Provenance

The following attestation bundles were made for easee_ble-1.0.0.tar.gz:

Publisher: publish.yml on parrel/easee-ble

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file easee_ble-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: easee_ble-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 33.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for easee_ble-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a5f46ba504aba0a09f24f7adbd077c00fe3c849f6c46de4f8646561cbb8af65a
MD5 6118492c4868eb38cff7b515befc2ccb
BLAKE2b-256 cff1c9713d6d5e836cd6e5d0633d9e1c559f259c88147d4cdd03349ed21be904

See more details on using hashes here.

Provenance

The following attestation bundles were made for easee_ble-1.0.0-py3-none-any.whl:

Publisher: publish.yml on parrel/easee-ble

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 files

0.2.0

2 files

0.1.1

2 files

0.1.0

2 files

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