easee-ble 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- easee_ble-0.1.0/CHANGELOG.md +5 -0
- easee_ble-0.1.0/LICENSE +21 -0
- easee_ble-0.1.0/MANIFEST.in +5 -0
- easee_ble-0.1.0/PKG-INFO +226 -0
- easee_ble-0.1.0/README.md +194 -0
- easee_ble-0.1.0/pyproject.toml +86 -0
- easee_ble-0.1.0/setup.cfg +4 -0
- easee_ble-0.1.0/src/easee_ble/__init__.py +107 -0
- easee_ble-0.1.0/src/easee_ble/client.py +589 -0
- easee_ble-0.1.0/src/easee_ble/commands.py +244 -0
- easee_ble-0.1.0/src/easee_ble/const.py +26 -0
- easee_ble-0.1.0/src/easee_ble/crypto.py +46 -0
- easee_ble-0.1.0/src/easee_ble/exceptions.py +39 -0
- easee_ble-0.1.0/src/easee_ble/frames.py +475 -0
- easee_ble-0.1.0/src/easee_ble/jpake.py +221 -0
- easee_ble-0.1.0/src/easee_ble/protocol.py +27 -0
- easee_ble-0.1.0/src/easee_ble/py.typed +0 -0
- easee_ble-0.1.0/src/easee_ble/session.py +173 -0
- easee_ble-0.1.0/src/easee_ble.egg-info/PKG-INFO +226 -0
- easee_ble-0.1.0/src/easee_ble.egg-info/SOURCES.txt +28 -0
- easee_ble-0.1.0/src/easee_ble.egg-info/dependency_links.txt +1 -0
- easee_ble-0.1.0/src/easee_ble.egg-info/requires.txt +11 -0
- easee_ble-0.1.0/src/easee_ble.egg-info/top_level.txt +1 -0
- easee_ble-0.1.0/tests/mbedtls_ecjpake_vectors.json +12 -0
- easee_ble-0.1.0/tests/test_client.py +276 -0
- easee_ble-0.1.0/tests/test_commands.py +85 -0
- easee_ble-0.1.0/tests/test_crypto.py +46 -0
- easee_ble-0.1.0/tests/test_frames.py +330 -0
- easee_ble-0.1.0/tests/test_invariants.py +146 -0
- easee_ble-0.1.0/tests/test_jpake.py +103 -0
easee_ble-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 parrel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
easee_ble-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: easee-ble
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local Bluetooth control for Easee EV chargers
|
|
5
|
+
Author: parrel
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/parrel/easee-ble
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/parrel/easee-ble/issues
|
|
9
|
+
Keywords: easee,ev,charger,bluetooth,ble,home-assistant
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Framework :: AsyncIO
|
|
17
|
+
Classifier: Topic :: Home Automation
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: pycryptodome>=3.20
|
|
22
|
+
Requires-Dist: ecdsa>=0.19
|
|
23
|
+
Requires-Dist: bleak>=0.22
|
|
24
|
+
Requires-Dist: bleak-retry-connector>=3.5.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# easee-ble
|
|
34
|
+
|
|
35
|
+
Local **Bluetooth** control for [Easee](https://easee.com) EV chargers. Talk to the charger directly with the PIN printed on the unit.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install easee-ble
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Requires Python 3.11+. For Home Assistant, use
|
|
42
|
+
[ha-easee-ble](https://github.com/parrel/ha-easee-ble), which is built on this.
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
from bleak import BleakScanner
|
|
49
|
+
from easee_ble import EaseeCharger, PhaseMode
|
|
50
|
+
|
|
51
|
+
async def main():
|
|
52
|
+
device = await BleakScanner.find_device_by_address("AA:BB:CC:DD:EE:FF")
|
|
53
|
+
charger = EaseeCharger(device, pin="1234", serial="EMX00000")
|
|
54
|
+
|
|
55
|
+
await charger.connect() # handshake; the link is held open
|
|
56
|
+
print(await charger.poll()) # {'chargerOpMode': 3, 'totalPower': 2.69, ...}
|
|
57
|
+
|
|
58
|
+
await charger.perform(lambda s: s.set_charger_enabled(True))
|
|
59
|
+
await charger.perform(lambda s: s.set_phase_mode(PhaseMode.AUTO))
|
|
60
|
+
|
|
61
|
+
await charger.disconnect()
|
|
62
|
+
|
|
63
|
+
asyncio.run(main())
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
You need two things from the charger: the **PIN** printed on the unit, and its
|
|
67
|
+
**serial** (`EMXXXXXX`, also on the unit and in the Easee app).
|
|
68
|
+
|
|
69
|
+
### Finding your charger
|
|
70
|
+
|
|
71
|
+
Scan by service UUID if you do not know the address:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from easee_ble import SERVICE_UUID
|
|
75
|
+
|
|
76
|
+
device = await BleakScanner.find_device_by_filter(
|
|
77
|
+
lambda d, adv: SERVICE_UUID in adv.service_uuids
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Chargers usually advertise only **intermittently**. To get one to show up,
|
|
82
|
+
either set Bluetooth to always-on in the Easee app, or open a window with a long
|
|
83
|
+
press of the charger's touch button.
|
|
84
|
+
|
|
85
|
+
## Reading
|
|
86
|
+
|
|
87
|
+
`poll()` merges State and Config into one dict of named fields:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
data = await charger.poll()
|
|
91
|
+
data["chargerOpMode"] # 3
|
|
92
|
+
data["totalPower"] # 2.69
|
|
93
|
+
data["maxChargerCurrent"] # 16
|
|
94
|
+
charger.unknown # fields we have no name for yet, by number
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Two fields belong together. `chargerOpMode` describes the **car and cable** - it
|
|
98
|
+
still reads `AWAITING_START` for a charger that has been switched off.
|
|
99
|
+
`reasonForNoCurrent` says **why** no current flows, and reads 53
|
|
100
|
+
(*charger disabled*) in exactly that case. Show both:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from easee_ble import charger_op_mode, reason_for_no_current
|
|
104
|
+
|
|
105
|
+
charger_op_mode(data["chargerOpMode"]) # ChargerOpMode.CHARGING
|
|
106
|
+
reason_for_no_current(data.get("reasonForNoCurrent")) # 'Charger disabled'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Both return `None` on a value this library has not seen, where `ChargerOpMode()`
|
|
110
|
+
and `NetworkStatus()` raise. The descriptions are display text; key on
|
|
111
|
+
`reason_for_no_current_slug()` if you need something stable.
|
|
112
|
+
|
|
113
|
+
Fields at their default value are **absent** from the wire, not zero. `poll()`
|
|
114
|
+
already fills a curated set back in as `0`, so treat a missing field as unknown
|
|
115
|
+
rather than surprising.
|
|
116
|
+
|
|
117
|
+
## Commands
|
|
118
|
+
|
|
119
|
+
Every command goes through `perform()`, which builds the request and checks the
|
|
120
|
+
answer:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
await charger.perform(lambda s: s.set_charger_enabled(True))
|
|
124
|
+
await charger.perform(lambda s: s.set_max_charger_current(16))
|
|
125
|
+
await charger.perform(lambda s: s.set_dynamic_charger_current(10))
|
|
126
|
+
await charger.perform(lambda s: s.set_circuit_max_current(20)) # or (p1, p2, p3)
|
|
127
|
+
await charger.perform(lambda s: s.set_offline_max_circuit_current(10))
|
|
128
|
+
await charger.perform(lambda s: s.set_phase_mode(PhaseMode.LOCKED_3_PHASE))
|
|
129
|
+
await charger.perform(lambda s: s.set_led_brightness(75)) # 0-100
|
|
130
|
+
await charger.perform(lambda s: s.set_cable_locked(True))
|
|
131
|
+
await charger.perform(lambda s: s.set_access_control(True))
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`PhaseMode` is `LOCKED_1_PHASE`, `AUTO` or `LOCKED_3_PHASE`.
|
|
135
|
+
|
|
136
|
+
### RFID / account keys
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from easee_ble import command_payload
|
|
140
|
+
|
|
141
|
+
reply = await charger.perform(lambda s: s.list_user_tokens())
|
|
142
|
+
keys = command_payload(reply)
|
|
143
|
+
await charger.perform(lambda s: s.get_user_token(slot, name))
|
|
144
|
+
await charger.perform(lambda s: s.set_user_token(slot, name, token))
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Other reads
|
|
148
|
+
|
|
149
|
+
Besides `poll()`, individual frames are available through `perform()`:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
await charger.perform(lambda s: s.poll_state())
|
|
153
|
+
await charger.perform(lambda s: s.poll_config())
|
|
154
|
+
await charger.perform(lambda s: s.poll_structure())
|
|
155
|
+
await charger.perform(lambda s: s.poll_debug())
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
A reply only arrives on a subscribed channel, and the last two are not
|
|
159
|
+
subscribed to by default. Ask for them at connect - `EaseeCharger(...,
|
|
160
|
+
channels=DEFAULT_CHANNELS | {Channel.DEBUG})` - or the call raises. `poll()`
|
|
161
|
+
takes the same argument to read them alongside Config and State.
|
|
162
|
+
|
|
163
|
+
## Errors
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from easee_ble import EaseeCommandRefused, EaseeConnectionError, JPakeError
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
await charger.connect()
|
|
170
|
+
await charger.perform(lambda s: s.set_max_charger_current(32))
|
|
171
|
+
except JPakeError: # almost always the wrong PIN
|
|
172
|
+
...
|
|
173
|
+
except EaseeCommandRefused: # the charger answered, and said no
|
|
174
|
+
...
|
|
175
|
+
except EaseeConnectionError: # connecting or talking to it failed
|
|
176
|
+
...
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
A reply is **not** an acknowledgement - the charger answers a command it refuses
|
|
180
|
+
just as promptly as one it accepts. `perform()` checks for you and raises
|
|
181
|
+
`EaseeCommandRefused`; anything else, `command_accepted()` and
|
|
182
|
+
`command_refusal()` check by hand.
|
|
183
|
+
|
|
184
|
+
## Connection notes
|
|
185
|
+
|
|
186
|
+
- The connection is **long-lived**: connect once and keep polling. Connecting is
|
|
187
|
+
the expensive, failure-prone part.
|
|
188
|
+
- **Not safe for concurrent use.** One request may be in flight at a time -
|
|
189
|
+
serialise with a lock if several tasks share a charger.
|
|
190
|
+
- Pass `on_disconnect=` to be told the moment the link drops rather than at your
|
|
191
|
+
next poll; check `charger.connected` before using it.
|
|
192
|
+
- The charger has a single connection slot. Always `disconnect()` when done.
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
charger = EaseeCharger(device, pin="1234", serial="EMX00000",
|
|
196
|
+
on_disconnect=lambda c: print("link lost"))
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Without bleak
|
|
200
|
+
|
|
201
|
+
`Session` is sans-io: it builds requests and parses replies and does no I/O, so
|
|
202
|
+
you can drive it over any transport.
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
from easee_ble import Session
|
|
206
|
+
|
|
207
|
+
s = Session(pin="1234", serial="EMX00000")
|
|
208
|
+
req = s.start_handshake() # write req.data to req.channel, feed the reply back
|
|
209
|
+
req = s.read_round_one(reply)
|
|
210
|
+
s.read_round_two(reply) # s.established is now True
|
|
211
|
+
frame = s.parse(req.channel, reply)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Development
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
pip install -e ".[dev]"
|
|
218
|
+
pytest
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Tests run against captured bytes from a real charger, so they need no hardware.
|
|
222
|
+
|
|
223
|
+
## Notes
|
|
224
|
+
|
|
225
|
+
**Unofficial.** Reverse-engineered. Not affiliated with or endorsed by Easee. No warranty, changing charger settings is at your own risk. Barely tested.
|
|
226
|
+
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# easee-ble
|
|
2
|
+
|
|
3
|
+
Local **Bluetooth** control for [Easee](https://easee.com) EV chargers. Talk to the charger directly with the PIN printed on the unit.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install easee-ble
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Requires Python 3.11+. For Home Assistant, use
|
|
10
|
+
[ha-easee-ble](https://github.com/parrel/ha-easee-ble), which is built on this.
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
import asyncio
|
|
16
|
+
from bleak import BleakScanner
|
|
17
|
+
from easee_ble import EaseeCharger, PhaseMode
|
|
18
|
+
|
|
19
|
+
async def main():
|
|
20
|
+
device = await BleakScanner.find_device_by_address("AA:BB:CC:DD:EE:FF")
|
|
21
|
+
charger = EaseeCharger(device, pin="1234", serial="EMX00000")
|
|
22
|
+
|
|
23
|
+
await charger.connect() # handshake; the link is held open
|
|
24
|
+
print(await charger.poll()) # {'chargerOpMode': 3, 'totalPower': 2.69, ...}
|
|
25
|
+
|
|
26
|
+
await charger.perform(lambda s: s.set_charger_enabled(True))
|
|
27
|
+
await charger.perform(lambda s: s.set_phase_mode(PhaseMode.AUTO))
|
|
28
|
+
|
|
29
|
+
await charger.disconnect()
|
|
30
|
+
|
|
31
|
+
asyncio.run(main())
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
You need two things from the charger: the **PIN** printed on the unit, and its
|
|
35
|
+
**serial** (`EMXXXXXX`, also on the unit and in the Easee app).
|
|
36
|
+
|
|
37
|
+
### Finding your charger
|
|
38
|
+
|
|
39
|
+
Scan by service UUID if you do not know the address:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from easee_ble import SERVICE_UUID
|
|
43
|
+
|
|
44
|
+
device = await BleakScanner.find_device_by_filter(
|
|
45
|
+
lambda d, adv: SERVICE_UUID in adv.service_uuids
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Chargers usually advertise only **intermittently**. To get one to show up,
|
|
50
|
+
either set Bluetooth to always-on in the Easee app, or open a window with a long
|
|
51
|
+
press of the charger's touch button.
|
|
52
|
+
|
|
53
|
+
## Reading
|
|
54
|
+
|
|
55
|
+
`poll()` merges State and Config into one dict of named fields:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
data = await charger.poll()
|
|
59
|
+
data["chargerOpMode"] # 3
|
|
60
|
+
data["totalPower"] # 2.69
|
|
61
|
+
data["maxChargerCurrent"] # 16
|
|
62
|
+
charger.unknown # fields we have no name for yet, by number
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Two fields belong together. `chargerOpMode` describes the **car and cable** - it
|
|
66
|
+
still reads `AWAITING_START` for a charger that has been switched off.
|
|
67
|
+
`reasonForNoCurrent` says **why** no current flows, and reads 53
|
|
68
|
+
(*charger disabled*) in exactly that case. Show both:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from easee_ble import charger_op_mode, reason_for_no_current
|
|
72
|
+
|
|
73
|
+
charger_op_mode(data["chargerOpMode"]) # ChargerOpMode.CHARGING
|
|
74
|
+
reason_for_no_current(data.get("reasonForNoCurrent")) # 'Charger disabled'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Both return `None` on a value this library has not seen, where `ChargerOpMode()`
|
|
78
|
+
and `NetworkStatus()` raise. The descriptions are display text; key on
|
|
79
|
+
`reason_for_no_current_slug()` if you need something stable.
|
|
80
|
+
|
|
81
|
+
Fields at their default value are **absent** from the wire, not zero. `poll()`
|
|
82
|
+
already fills a curated set back in as `0`, so treat a missing field as unknown
|
|
83
|
+
rather than surprising.
|
|
84
|
+
|
|
85
|
+
## Commands
|
|
86
|
+
|
|
87
|
+
Every command goes through `perform()`, which builds the request and checks the
|
|
88
|
+
answer:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
await charger.perform(lambda s: s.set_charger_enabled(True))
|
|
92
|
+
await charger.perform(lambda s: s.set_max_charger_current(16))
|
|
93
|
+
await charger.perform(lambda s: s.set_dynamic_charger_current(10))
|
|
94
|
+
await charger.perform(lambda s: s.set_circuit_max_current(20)) # or (p1, p2, p3)
|
|
95
|
+
await charger.perform(lambda s: s.set_offline_max_circuit_current(10))
|
|
96
|
+
await charger.perform(lambda s: s.set_phase_mode(PhaseMode.LOCKED_3_PHASE))
|
|
97
|
+
await charger.perform(lambda s: s.set_led_brightness(75)) # 0-100
|
|
98
|
+
await charger.perform(lambda s: s.set_cable_locked(True))
|
|
99
|
+
await charger.perform(lambda s: s.set_access_control(True))
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`PhaseMode` is `LOCKED_1_PHASE`, `AUTO` or `LOCKED_3_PHASE`.
|
|
103
|
+
|
|
104
|
+
### RFID / account keys
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from easee_ble import command_payload
|
|
108
|
+
|
|
109
|
+
reply = await charger.perform(lambda s: s.list_user_tokens())
|
|
110
|
+
keys = command_payload(reply)
|
|
111
|
+
await charger.perform(lambda s: s.get_user_token(slot, name))
|
|
112
|
+
await charger.perform(lambda s: s.set_user_token(slot, name, token))
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Other reads
|
|
116
|
+
|
|
117
|
+
Besides `poll()`, individual frames are available through `perform()`:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
await charger.perform(lambda s: s.poll_state())
|
|
121
|
+
await charger.perform(lambda s: s.poll_config())
|
|
122
|
+
await charger.perform(lambda s: s.poll_structure())
|
|
123
|
+
await charger.perform(lambda s: s.poll_debug())
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A reply only arrives on a subscribed channel, and the last two are not
|
|
127
|
+
subscribed to by default. Ask for them at connect - `EaseeCharger(...,
|
|
128
|
+
channels=DEFAULT_CHANNELS | {Channel.DEBUG})` - or the call raises. `poll()`
|
|
129
|
+
takes the same argument to read them alongside Config and State.
|
|
130
|
+
|
|
131
|
+
## Errors
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from easee_ble import EaseeCommandRefused, EaseeConnectionError, JPakeError
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
await charger.connect()
|
|
138
|
+
await charger.perform(lambda s: s.set_max_charger_current(32))
|
|
139
|
+
except JPakeError: # almost always the wrong PIN
|
|
140
|
+
...
|
|
141
|
+
except EaseeCommandRefused: # the charger answered, and said no
|
|
142
|
+
...
|
|
143
|
+
except EaseeConnectionError: # connecting or talking to it failed
|
|
144
|
+
...
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
A reply is **not** an acknowledgement - the charger answers a command it refuses
|
|
148
|
+
just as promptly as one it accepts. `perform()` checks for you and raises
|
|
149
|
+
`EaseeCommandRefused`; anything else, `command_accepted()` and
|
|
150
|
+
`command_refusal()` check by hand.
|
|
151
|
+
|
|
152
|
+
## Connection notes
|
|
153
|
+
|
|
154
|
+
- The connection is **long-lived**: connect once and keep polling. Connecting is
|
|
155
|
+
the expensive, failure-prone part.
|
|
156
|
+
- **Not safe for concurrent use.** One request may be in flight at a time -
|
|
157
|
+
serialise with a lock if several tasks share a charger.
|
|
158
|
+
- Pass `on_disconnect=` to be told the moment the link drops rather than at your
|
|
159
|
+
next poll; check `charger.connected` before using it.
|
|
160
|
+
- The charger has a single connection slot. Always `disconnect()` when done.
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
charger = EaseeCharger(device, pin="1234", serial="EMX00000",
|
|
164
|
+
on_disconnect=lambda c: print("link lost"))
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Without bleak
|
|
168
|
+
|
|
169
|
+
`Session` is sans-io: it builds requests and parses replies and does no I/O, so
|
|
170
|
+
you can drive it over any transport.
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from easee_ble import Session
|
|
174
|
+
|
|
175
|
+
s = Session(pin="1234", serial="EMX00000")
|
|
176
|
+
req = s.start_handshake() # write req.data to req.channel, feed the reply back
|
|
177
|
+
req = s.read_round_one(reply)
|
|
178
|
+
s.read_round_two(reply) # s.established is now True
|
|
179
|
+
frame = s.parse(req.channel, reply)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Development
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
pip install -e ".[dev]"
|
|
186
|
+
pytest
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Tests run against captured bytes from a real charger, so they need no hardware.
|
|
190
|
+
|
|
191
|
+
## Notes
|
|
192
|
+
|
|
193
|
+
**Unofficial.** Reverse-engineered. Not affiliated with or endorsed by Easee. No warranty, changing charger settings is at your own risk. Barely tested.
|
|
194
|
+
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "easee-ble"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Local Bluetooth control for Easee EV chargers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "parrel" }]
|
|
13
|
+
keywords = ["easee", "ev", "charger", "bluetooth", "ble", "home-assistant"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Framework :: AsyncIO",
|
|
22
|
+
"Topic :: Home Automation",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
# A 4-byte GCM nonce is legal but `cryptography` refuses any IV under 8
|
|
26
|
+
# bytes, so this cannot be swapped for it. See easee_ble/crypto.py.
|
|
27
|
+
"pycryptodome>=3.20",
|
|
28
|
+
# EC-JPAKE needs raw P-256 point arithmetic, which `cryptography` does not
|
|
29
|
+
# expose. Note ecdsa's own README: it is not side-channel hardened.
|
|
30
|
+
"ecdsa>=0.19",
|
|
31
|
+
"bleak>=0.22",
|
|
32
|
+
"bleak-retry-connector>=3.5.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Repository = "https://github.com/parrel/easee-ble"
|
|
37
|
+
"Bug Tracker" = "https://github.com/parrel/easee-ble/issues"
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
dev = [
|
|
41
|
+
"pytest>=8",
|
|
42
|
+
"pytest-asyncio>=0.23",
|
|
43
|
+
"pytest-cov>=5",
|
|
44
|
+
"ruff>=0.6",
|
|
45
|
+
"mypy>=1.11",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.dynamic]
|
|
49
|
+
version = { attr = "easee_ble.__version__" }
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.packages.find]
|
|
52
|
+
where = ["src"]
|
|
53
|
+
|
|
54
|
+
[tool.setuptools.package-data]
|
|
55
|
+
easee_ble = ["py.typed"]
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
testpaths = ["tests"]
|
|
59
|
+
# The client tests are async; without this every one of them needs a decorator.
|
|
60
|
+
asyncio_mode = "auto"
|
|
61
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
62
|
+
|
|
63
|
+
# `py.typed` promises callers that these annotations are checked, so check them.
|
|
64
|
+
[tool.mypy]
|
|
65
|
+
python_version = "3.11"
|
|
66
|
+
mypy_path = "src"
|
|
67
|
+
packages = ["easee_ble"]
|
|
68
|
+
strict = true
|
|
69
|
+
# The library is annotated; its dependencies are not all shipped with types.
|
|
70
|
+
[[tool.mypy.overrides]]
|
|
71
|
+
module = ["Crypto.*", "ecdsa.*", "bleak_retry_connector.*"]
|
|
72
|
+
ignore_missing_imports = true
|
|
73
|
+
|
|
74
|
+
[tool.ruff]
|
|
75
|
+
line-length = 100
|
|
76
|
+
target-version = "py311"
|
|
77
|
+
|
|
78
|
+
# Pinned explicitly rather than left to ruff's defaults, which shift between
|
|
79
|
+
# releases - CI should not start failing because a new ruff was published.
|
|
80
|
+
[tool.ruff.lint]
|
|
81
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
82
|
+
ignore = [
|
|
83
|
+
# __all__ is grouped by role, with comments. Alphabetising it would destroy
|
|
84
|
+
# the only structure it has.
|
|
85
|
+
"RUF022",
|
|
86
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Local BLE control for Easee EV chargers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from . import commands, crypto, frames, protocol
|
|
6
|
+
from .client import DEFAULT_CHANNELS, DEFAULT_POLL_CHANNELS, EaseeCharger
|
|
7
|
+
from .commands import BtEnableMode, Command, NetworkStatus, PhaseMode, network_status
|
|
8
|
+
from .const import HANDSHAKE_MTU
|
|
9
|
+
from .crypto import decrypt, encrypt
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
EaseeCommandRefused,
|
|
12
|
+
EaseeConnectionError,
|
|
13
|
+
EaseeError,
|
|
14
|
+
FrameError,
|
|
15
|
+
IncompleteFrame,
|
|
16
|
+
JPakeError,
|
|
17
|
+
ProtocolError,
|
|
18
|
+
RecordError,
|
|
19
|
+
SessionError,
|
|
20
|
+
)
|
|
21
|
+
from .frames import (
|
|
22
|
+
CONFIRMED_REASON_CODES,
|
|
23
|
+
FIELD_ALIASES,
|
|
24
|
+
FIELD_UNITS,
|
|
25
|
+
REASON_FOR_NO_CURRENT,
|
|
26
|
+
REASON_FOR_NO_CURRENT_SLUGS,
|
|
27
|
+
ZERO_WHEN_ABSENT,
|
|
28
|
+
ChargerOpMode,
|
|
29
|
+
Frame,
|
|
30
|
+
MessageType,
|
|
31
|
+
charger_op_mode,
|
|
32
|
+
command_accepted,
|
|
33
|
+
command_payload,
|
|
34
|
+
command_refusal,
|
|
35
|
+
declared_length,
|
|
36
|
+
parse_command_response,
|
|
37
|
+
parse_response,
|
|
38
|
+
reason_for_no_current,
|
|
39
|
+
reason_for_no_current_slug,
|
|
40
|
+
with_aliases,
|
|
41
|
+
)
|
|
42
|
+
from .jpake import ECJPake
|
|
43
|
+
from .protocol import CHANNEL_BY_UUID, MANUFACTURER_ID, SERVICE_UUID, Channel
|
|
44
|
+
from .session import Request, Session
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
# client
|
|
48
|
+
"EaseeCharger",
|
|
49
|
+
# sans-io core
|
|
50
|
+
"Session",
|
|
51
|
+
"Request",
|
|
52
|
+
"ECJPake",
|
|
53
|
+
"encrypt",
|
|
54
|
+
"decrypt",
|
|
55
|
+
# commands and their value types
|
|
56
|
+
"Command",
|
|
57
|
+
"PhaseMode",
|
|
58
|
+
"BtEnableMode",
|
|
59
|
+
"NetworkStatus",
|
|
60
|
+
"network_status",
|
|
61
|
+
# responses
|
|
62
|
+
"Frame",
|
|
63
|
+
"MessageType",
|
|
64
|
+
"ChargerOpMode",
|
|
65
|
+
"charger_op_mode",
|
|
66
|
+
"REASON_FOR_NO_CURRENT",
|
|
67
|
+
"REASON_FOR_NO_CURRENT_SLUGS",
|
|
68
|
+
"CONFIRMED_REASON_CODES",
|
|
69
|
+
"FIELD_UNITS",
|
|
70
|
+
"reason_for_no_current",
|
|
71
|
+
"reason_for_no_current_slug",
|
|
72
|
+
"ZERO_WHEN_ABSENT",
|
|
73
|
+
# renaming a shipped field name; see "Field name stability" in the README
|
|
74
|
+
"FIELD_ALIASES",
|
|
75
|
+
"with_aliases",
|
|
76
|
+
"declared_length",
|
|
77
|
+
"parse_response",
|
|
78
|
+
"parse_command_response",
|
|
79
|
+
"command_accepted",
|
|
80
|
+
"command_payload",
|
|
81
|
+
"command_refusal",
|
|
82
|
+
# transport constants worth knowing about
|
|
83
|
+
"SERVICE_UUID",
|
|
84
|
+
"MANUFACTURER_ID",
|
|
85
|
+
"CHANNEL_BY_UUID",
|
|
86
|
+
"Channel",
|
|
87
|
+
"HANDSHAKE_MTU",
|
|
88
|
+
"DEFAULT_CHANNELS",
|
|
89
|
+
"DEFAULT_POLL_CHANNELS",
|
|
90
|
+
# exceptions
|
|
91
|
+
"EaseeError",
|
|
92
|
+
"EaseeConnectionError",
|
|
93
|
+
"EaseeCommandRefused",
|
|
94
|
+
"ProtocolError",
|
|
95
|
+
"FrameError",
|
|
96
|
+
"IncompleteFrame",
|
|
97
|
+
"RecordError",
|
|
98
|
+
"SessionError",
|
|
99
|
+
"JPakeError",
|
|
100
|
+
# submodules
|
|
101
|
+
"commands",
|
|
102
|
+
"crypto",
|
|
103
|
+
"frames",
|
|
104
|
+
"protocol",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
__version__ = "0.1.0"
|