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-0.2.0.tar.gz (50.3 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-0.2.0-py3-none-any.whl (33.8 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for easee_ble-0.2.0.tar.gz
Algorithm Hash digest
SHA256 b1287b3cc98375c2af3d1b9a38ba48595e52a6a88f203222ecf8944f04157e28
MD5 e5287c3fc488769610fac4d012fb46a7
BLAKE2b-256 719d9881ab9f551ce0afe62a7785751db68c2cb0ddee10652dbbdfe49da1bbb2

See more details on using hashes here.

Provenance

The following attestation bundles were made for easee_ble-0.2.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-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: easee_ble-0.2.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-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 bec84bbbbdabd882c935d7621d8d6a40464b7f8ae2af514bbbb91d87d1e08ef9
MD5 a84ce5670903f8a30d2f97253554d647
BLAKE2b-256 52618a00934af47b20309d38331a8877955e571f2f1b9ea75454bc21aad7a264

See more details on using hashes here.

Provenance

The following attestation bundles were made for easee_ble-0.2.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

1.0.0

2 files

This release

0.2.0 This release

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