spinev-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.
- spinev_ble-0.1.0/.gitignore +33 -0
- spinev_ble-0.1.0/LICENSE +21 -0
- spinev_ble-0.1.0/PKG-INFO +203 -0
- spinev_ble-0.1.0/README.md +177 -0
- spinev_ble-0.1.0/pyproject.toml +130 -0
- spinev_ble-0.1.0/src/spinev_ble/__init__.py +136 -0
- spinev_ble-0.1.0/src/spinev_ble/__main__.py +212 -0
- spinev_ble-0.1.0/src/spinev_ble/client.py +555 -0
- spinev_ble-0.1.0/src/spinev_ble/const.py +170 -0
- spinev_ble-0.1.0/src/spinev_ble/exceptions.py +36 -0
- spinev_ble-0.1.0/src/spinev_ble/models.py +94 -0
- spinev_ble-0.1.0/src/spinev_ble/protocol.py +241 -0
- spinev_ble-0.1.0/src/spinev_ble/py.typed +0 -0
- spinev_ble-0.1.0/tests/__init__.py +0 -0
- spinev_ble-0.1.0/tests/conftest.py +125 -0
- spinev_ble-0.1.0/tests/test_cli.py +91 -0
- spinev_ble-0.1.0/tests/test_client.py +444 -0
- spinev_ble-0.1.0/tests/test_protocol.py +248 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.so
|
|
4
|
+
.Python
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.mypy_cache/
|
|
12
|
+
.ruff_cache/
|
|
13
|
+
.coverage
|
|
14
|
+
coverage.xml
|
|
15
|
+
htmlcov/
|
|
16
|
+
.tox/
|
|
17
|
+
|
|
18
|
+
# editor settings are personal; shared formatting lives in .editorconfig and
|
|
19
|
+
# the tool configuration in pyproject.toml
|
|
20
|
+
.idea/
|
|
21
|
+
# .vscode/* rather than .vscode/, so the shared extension recommendations below
|
|
22
|
+
# can be re-included. Git cannot un-ignore a file inside an ignored directory.
|
|
23
|
+
.vscode/*
|
|
24
|
+
!.vscode/extensions.json
|
|
25
|
+
!.vscode/tasks.json
|
|
26
|
+
*.swp
|
|
27
|
+
.DS_Store
|
|
28
|
+
|
|
29
|
+
# never commit charger credentials or captures
|
|
30
|
+
*.btsnoop
|
|
31
|
+
btsnoop_hci.log
|
|
32
|
+
secrets.py
|
|
33
|
+
.env
|
spinev_ble-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dr-Blank
|
|
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.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spinev-ble
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: local bluetooth le control for exicom spin ev chargers
|
|
5
|
+
Project-URL: Homepage, https://github.com/Dr-Blank/spinev-ble
|
|
6
|
+
Project-URL: Repository, https://github.com/Dr-Blank/spinev-ble
|
|
7
|
+
Project-URL: Issues, https://github.com/Dr-Blank/spinev-ble/issues
|
|
8
|
+
Author-email: Dr-Blank <64108942+Dr-Blank@users.noreply.github.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ble,bleak,bluetooth,ev charger,evse,exicom,home assistant,spin air,spin ev
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Home Automation
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Provides-Extra: bleak
|
|
24
|
+
Requires-Dist: bleak>=0.22.0; extra == 'bleak'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# spinev-ble
|
|
28
|
+
|
|
29
|
+
Local Bluetooth LE control for Exicom Spin EV chargers.
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
33
|
+
No cloud, no vendor app, no account, no internet. Not affiliated with Exicom.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install spinev-ble # codec only, zero dependencies
|
|
39
|
+
pip install spinev-ble[bleak] # adds the optional BLE client and CLI
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Python 3.11 or newer.
|
|
43
|
+
|
|
44
|
+
## Just the protocol
|
|
45
|
+
|
|
46
|
+
Nothing here needs a Bluetooth stack. Build a frame, send it however you like, decode what comes back.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from spinev_ble import Command, Register, build_control, build_read
|
|
50
|
+
from spinev_ble import parse_frame, decode_float, decode_uint, ChargerState
|
|
51
|
+
|
|
52
|
+
build_control(Command.START, password=0xABCDEF).hex() # '10ac3c0101abcdef'
|
|
53
|
+
build_control(Command.STOP, password=0xABCDEF).hex() # '10ac3c0110abcdef'
|
|
54
|
+
build_read(Register.POWER).hex() # '10ac840000000000'
|
|
55
|
+
|
|
56
|
+
reply = parse_frame(bytes.fromhex("10ac840045713d71"))
|
|
57
|
+
decode_float(reply.raw) # 3859.84 watts
|
|
58
|
+
|
|
59
|
+
state = decode_uint(bytes.fromhex("00000004"))
|
|
60
|
+
ChargerState(state) # ChargerState.CHARGING
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
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.
|
|
64
|
+
|
|
65
|
+
## Optional BLE client
|
|
66
|
+
|
|
67
|
+
Needs the `bleak` extra. Convenience only, the codec above is the real library.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import asyncio
|
|
71
|
+
|
|
72
|
+
from bleak import BleakScanner
|
|
73
|
+
from spinev_ble import SpinEvCharger
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def main() -> None:
|
|
77
|
+
device = await BleakScanner.find_device_by_address("AA:BB:CC:DD:EE:FF")
|
|
78
|
+
if device is None:
|
|
79
|
+
raise SystemExit("charger not found, is the phone app connected to it?")
|
|
80
|
+
|
|
81
|
+
async with SpinEvCharger(device, password=0xABCDEF) as charger:
|
|
82
|
+
status = await charger.async_get_status()
|
|
83
|
+
print(status.state.name, status.power_w, "W")
|
|
84
|
+
|
|
85
|
+
await charger.async_start_charging()
|
|
86
|
+
await asyncio.sleep(3)
|
|
87
|
+
await charger.async_stop_charging()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
asyncio.run(main())
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`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.
|
|
94
|
+
|
|
95
|
+
Register reads are logged as an id and length, never the decoded value, since some registers hold credentials.
|
|
96
|
+
|
|
97
|
+
### Custom transports
|
|
98
|
+
|
|
99
|
+
`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:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from spinev_ble import SpinEvCharger
|
|
103
|
+
|
|
104
|
+
charger = SpinEvCharger(device, password, client_class=MyTransport)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
It is called as `client_class(device, timeout=..., disconnected_callback=...)`.
|
|
108
|
+
|
|
109
|
+
## Command line
|
|
110
|
+
|
|
111
|
+
Installed with the `bleak` extra.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
spinev-ble scan # find chargers nearby
|
|
115
|
+
spinev-ble password # ask the charger for its own password
|
|
116
|
+
spinev-ble status # live telemetry
|
|
117
|
+
spinev-ble history # stored charging sessions
|
|
118
|
+
spinev-ble start # start charging
|
|
119
|
+
spinev-ble stop # stop charging
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Or `python -m spinev_ble ...` if you prefer.
|
|
123
|
+
|
|
124
|
+
Pass the password through the environment rather than `--password`, so it stays out of your shell history and out of the process list:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
export SPINEV_BLE_PASSWORD=0xABCDEF
|
|
128
|
+
spinev-ble start
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Your charger's Bluetooth password
|
|
132
|
+
|
|
133
|
+
Start and stop commands carry a per charger password. Reads do not need it, so telemetry works without one.
|
|
134
|
+
|
|
135
|
+
Ask the charger for it:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
spinev-ble password
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
That reads register `0x32` on your own charger.
|
|
142
|
+
|
|
143
|
+
Treat it as a credential: do not commit it, and do not paste it into an issue.
|
|
144
|
+
|
|
145
|
+
## What is supported
|
|
146
|
+
|
|
147
|
+
| Feature | Register | API |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| Start and stop charging | `0x3C` | `async_start_charging`, `async_stop_charging` |
|
|
150
|
+
| Charger state | `0x67` | `async_get_state`, `async_get_state_value` |
|
|
151
|
+
| Active power, voltage, current | `0x84`, `0x0A`, `0x14` | `async_get_power`, `async_get_voltage`, `async_get_current` |
|
|
152
|
+
| Session energy and duration | `0x35`, `0x59` | `async_get_status` |
|
|
153
|
+
| Lifetime energy and duration | `0x65`, `0x6A` | `async_get_status` |
|
|
154
|
+
| Charging history | `0x68` | `async_get_history` |
|
|
155
|
+
| Firmware version | `0x52` | `async_get_status` |
|
|
156
|
+
| Active alarms | `0x39` | `async_get_alarms` |
|
|
157
|
+
| Charging current limit | `0x4F` | `async_get_current_limit`, `async_set_current_limit` |
|
|
158
|
+
| Charger password | `0x32` | `async_get_password` |
|
|
159
|
+
| WiFi settings | `0x61`, `0x63` | `async_get_wifi_ssid`, `async_set_wifi` |
|
|
160
|
+
| OCPP settings | `0x5E`, `0x60`, `0x62`, `0x64` | `async_get_ocpp_config`, `async_set_ocpp_config` |
|
|
161
|
+
|
|
162
|
+
Only the first alarm bank is decoded. A second bank exists, but its bit assignments are not known.
|
|
163
|
+
|
|
164
|
+
## Things worth knowing
|
|
165
|
+
|
|
166
|
+
**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.
|
|
167
|
+
|
|
168
|
+
**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.
|
|
169
|
+
|
|
170
|
+
**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.
|
|
171
|
+
|
|
172
|
+
**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.
|
|
173
|
+
|
|
174
|
+
**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.
|
|
175
|
+
|
|
176
|
+
**History is a rolling window.** The charger keeps only recent sessions, not everything.
|
|
177
|
+
|
|
178
|
+
**Do not poll hard.** Once every thirty seconds is plenty for monitoring, and five seconds is enough for a live power graph.
|
|
179
|
+
|
|
180
|
+
## Development
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
uv sync
|
|
184
|
+
uv run pytest
|
|
185
|
+
uv run prek run --all-files
|
|
186
|
+
uv run pylint src/spinev_ble
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Install the git hook so `prek` runs automatically on commit:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
uv run prek install
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Disclaimer
|
|
196
|
+
|
|
197
|
+
Independent, unofficial project providing Python API bindings for local access. Not affiliated with, endorsed by, or supported by Exicom.
|
|
198
|
+
|
|
199
|
+
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.
|
|
200
|
+
|
|
201
|
+
## Licence
|
|
202
|
+
|
|
203
|
+
MIT
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# spinev-ble
|
|
2
|
+
|
|
3
|
+
Local Bluetooth LE control for Exicom Spin EV chargers.
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
No cloud, no vendor app, no account, no internet. Not affiliated with Exicom.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install spinev-ble # codec only, zero dependencies
|
|
13
|
+
pip install spinev-ble[bleak] # adds the optional BLE client and CLI
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Python 3.11 or newer.
|
|
17
|
+
|
|
18
|
+
## Just the protocol
|
|
19
|
+
|
|
20
|
+
Nothing here needs a Bluetooth stack. Build a frame, send it however you like, decode what comes back.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from spinev_ble import Command, Register, build_control, build_read
|
|
24
|
+
from spinev_ble import parse_frame, decode_float, decode_uint, ChargerState
|
|
25
|
+
|
|
26
|
+
build_control(Command.START, password=0xABCDEF).hex() # '10ac3c0101abcdef'
|
|
27
|
+
build_control(Command.STOP, password=0xABCDEF).hex() # '10ac3c0110abcdef'
|
|
28
|
+
build_read(Register.POWER).hex() # '10ac840000000000'
|
|
29
|
+
|
|
30
|
+
reply = parse_frame(bytes.fromhex("10ac840045713d71"))
|
|
31
|
+
decode_float(reply.raw) # 3859.84 watts
|
|
32
|
+
|
|
33
|
+
state = decode_uint(bytes.fromhex("00000004"))
|
|
34
|
+
ChargerState(state) # ChargerState.CHARGING
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
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.
|
|
38
|
+
|
|
39
|
+
## Optional BLE client
|
|
40
|
+
|
|
41
|
+
Needs the `bleak` extra. Convenience only, the codec above is the real library.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import asyncio
|
|
45
|
+
|
|
46
|
+
from bleak import BleakScanner
|
|
47
|
+
from spinev_ble import SpinEvCharger
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def main() -> None:
|
|
51
|
+
device = await BleakScanner.find_device_by_address("AA:BB:CC:DD:EE:FF")
|
|
52
|
+
if device is None:
|
|
53
|
+
raise SystemExit("charger not found, is the phone app connected to it?")
|
|
54
|
+
|
|
55
|
+
async with SpinEvCharger(device, password=0xABCDEF) as charger:
|
|
56
|
+
status = await charger.async_get_status()
|
|
57
|
+
print(status.state.name, status.power_w, "W")
|
|
58
|
+
|
|
59
|
+
await charger.async_start_charging()
|
|
60
|
+
await asyncio.sleep(3)
|
|
61
|
+
await charger.async_stop_charging()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
asyncio.run(main())
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`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.
|
|
68
|
+
|
|
69
|
+
Register reads are logged as an id and length, never the decoded value, since some registers hold credentials.
|
|
70
|
+
|
|
71
|
+
### Custom transports
|
|
72
|
+
|
|
73
|
+
`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:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from spinev_ble import SpinEvCharger
|
|
77
|
+
|
|
78
|
+
charger = SpinEvCharger(device, password, client_class=MyTransport)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
It is called as `client_class(device, timeout=..., disconnected_callback=...)`.
|
|
82
|
+
|
|
83
|
+
## Command line
|
|
84
|
+
|
|
85
|
+
Installed with the `bleak` extra.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
spinev-ble scan # find chargers nearby
|
|
89
|
+
spinev-ble password # ask the charger for its own password
|
|
90
|
+
spinev-ble status # live telemetry
|
|
91
|
+
spinev-ble history # stored charging sessions
|
|
92
|
+
spinev-ble start # start charging
|
|
93
|
+
spinev-ble stop # stop charging
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Or `python -m spinev_ble ...` if you prefer.
|
|
97
|
+
|
|
98
|
+
Pass the password through the environment rather than `--password`, so it stays out of your shell history and out of the process list:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
export SPINEV_BLE_PASSWORD=0xABCDEF
|
|
102
|
+
spinev-ble start
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Your charger's Bluetooth password
|
|
106
|
+
|
|
107
|
+
Start and stop commands carry a per charger password. Reads do not need it, so telemetry works without one.
|
|
108
|
+
|
|
109
|
+
Ask the charger for it:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
spinev-ble password
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
That reads register `0x32` on your own charger.
|
|
116
|
+
|
|
117
|
+
Treat it as a credential: do not commit it, and do not paste it into an issue.
|
|
118
|
+
|
|
119
|
+
## What is supported
|
|
120
|
+
|
|
121
|
+
| Feature | Register | API |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| Start and stop charging | `0x3C` | `async_start_charging`, `async_stop_charging` |
|
|
124
|
+
| Charger state | `0x67` | `async_get_state`, `async_get_state_value` |
|
|
125
|
+
| Active power, voltage, current | `0x84`, `0x0A`, `0x14` | `async_get_power`, `async_get_voltage`, `async_get_current` |
|
|
126
|
+
| Session energy and duration | `0x35`, `0x59` | `async_get_status` |
|
|
127
|
+
| Lifetime energy and duration | `0x65`, `0x6A` | `async_get_status` |
|
|
128
|
+
| Charging history | `0x68` | `async_get_history` |
|
|
129
|
+
| Firmware version | `0x52` | `async_get_status` |
|
|
130
|
+
| Active alarms | `0x39` | `async_get_alarms` |
|
|
131
|
+
| Charging current limit | `0x4F` | `async_get_current_limit`, `async_set_current_limit` |
|
|
132
|
+
| Charger password | `0x32` | `async_get_password` |
|
|
133
|
+
| WiFi settings | `0x61`, `0x63` | `async_get_wifi_ssid`, `async_set_wifi` |
|
|
134
|
+
| OCPP settings | `0x5E`, `0x60`, `0x62`, `0x64` | `async_get_ocpp_config`, `async_set_ocpp_config` |
|
|
135
|
+
|
|
136
|
+
Only the first alarm bank is decoded. A second bank exists, but its bit assignments are not known.
|
|
137
|
+
|
|
138
|
+
## Things worth knowing
|
|
139
|
+
|
|
140
|
+
**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.
|
|
141
|
+
|
|
142
|
+
**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.
|
|
143
|
+
|
|
144
|
+
**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.
|
|
145
|
+
|
|
146
|
+
**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.
|
|
147
|
+
|
|
148
|
+
**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.
|
|
149
|
+
|
|
150
|
+
**History is a rolling window.** The charger keeps only recent sessions, not everything.
|
|
151
|
+
|
|
152
|
+
**Do not poll hard.** Once every thirty seconds is plenty for monitoring, and five seconds is enough for a live power graph.
|
|
153
|
+
|
|
154
|
+
## Development
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
uv sync
|
|
158
|
+
uv run pytest
|
|
159
|
+
uv run prek run --all-files
|
|
160
|
+
uv run pylint src/spinev_ble
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Install the git hook so `prek` runs automatically on commit:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
uv run prek install
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Disclaimer
|
|
170
|
+
|
|
171
|
+
Independent, unofficial project providing Python API bindings for local access. Not affiliated with, endorsed by, or supported by Exicom.
|
|
172
|
+
|
|
173
|
+
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.
|
|
174
|
+
|
|
175
|
+
## Licence
|
|
176
|
+
|
|
177
|
+
MIT
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "spinev-ble"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "local bluetooth le control for exicom spin ev chargers"
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Dr-Blank", email = "64108942+Dr-Blank@users.noreply.github.com" },
|
|
7
|
+
]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
keywords = [
|
|
12
|
+
"exicom",
|
|
13
|
+
"spin ev",
|
|
14
|
+
"spin air",
|
|
15
|
+
"ev charger",
|
|
16
|
+
"evse",
|
|
17
|
+
"bluetooth",
|
|
18
|
+
"ble",
|
|
19
|
+
"home assistant",
|
|
20
|
+
"bleak",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 3 - Alpha",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Topic :: Home Automation",
|
|
31
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
32
|
+
"Typing :: Typed",
|
|
33
|
+
]
|
|
34
|
+
dependencies = []
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
# Only needed if you use the bundled bleak client or the CLI. The protocol codec
|
|
38
|
+
# itself has no dependencies at all.
|
|
39
|
+
bleak = ["bleak>=0.22.0"]
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
spinev-ble = "spinev_ble.__main__:main"
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/Dr-Blank/spinev-ble"
|
|
46
|
+
Repository = "https://github.com/Dr-Blank/spinev-ble"
|
|
47
|
+
Issues = "https://github.com/Dr-Blank/spinev-ble/issues"
|
|
48
|
+
|
|
49
|
+
[dependency-groups]
|
|
50
|
+
dev = [
|
|
51
|
+
"bleak>=0.22.0",
|
|
52
|
+
"pytest>=8.4.1",
|
|
53
|
+
"pytest-asyncio>=0.24.0",
|
|
54
|
+
"pytest-cov>=6.2.1",
|
|
55
|
+
"ruff>=0.9.0",
|
|
56
|
+
"mypy>=1.15.0",
|
|
57
|
+
"pylint>=3.3.7",
|
|
58
|
+
"prek>=0.2.0",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[build-system]
|
|
62
|
+
requires = ["hatchling"]
|
|
63
|
+
build-backend = "hatchling.build"
|
|
64
|
+
|
|
65
|
+
[tool.hatch.build.targets.wheel]
|
|
66
|
+
packages = ["src/spinev_ble"]
|
|
67
|
+
|
|
68
|
+
[tool.hatch.build.targets.sdist]
|
|
69
|
+
include = ["src/spinev_ble", "tests", "README.md", "LICENSE"]
|
|
70
|
+
|
|
71
|
+
[tool.ruff]
|
|
72
|
+
line-length = 88
|
|
73
|
+
target-version = "py311"
|
|
74
|
+
src = ["src", "tests"]
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint]
|
|
77
|
+
select = [
|
|
78
|
+
"E", # pycodestyle errors
|
|
79
|
+
"W", # pycodestyle warnings
|
|
80
|
+
"F", # pyflakes
|
|
81
|
+
"I", # isort
|
|
82
|
+
"B", # flake8-bugbear
|
|
83
|
+
"C4", # flake8-comprehensions
|
|
84
|
+
"UP", # pyupgrade
|
|
85
|
+
"ARG", # flake8-unused-arguments
|
|
86
|
+
"SIM", # flake8-simplify
|
|
87
|
+
"RUF", # ruff specific
|
|
88
|
+
"ASYNC",
|
|
89
|
+
]
|
|
90
|
+
ignore = [
|
|
91
|
+
# Prose in docstrings and comments is not wrapped. The formatter still keeps
|
|
92
|
+
# code within line-length; long sentences are left alone deliberately.
|
|
93
|
+
"E501",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
[tool.ruff.lint.per-file-ignores]
|
|
97
|
+
"tests/*" = ["ARG001", "ARG002"]
|
|
98
|
+
|
|
99
|
+
[tool.mypy]
|
|
100
|
+
python_version = "3.11"
|
|
101
|
+
strict = true
|
|
102
|
+
warn_unreachable = true
|
|
103
|
+
disallow_untyped_defs = true
|
|
104
|
+
files = ["src/spinev_ble", "tests"]
|
|
105
|
+
|
|
106
|
+
[[tool.mypy.overrides]]
|
|
107
|
+
module = ["bleak_retry_connector.*"]
|
|
108
|
+
ignore_missing_imports = true
|
|
109
|
+
|
|
110
|
+
[tool.pytest.ini_options]
|
|
111
|
+
asyncio_mode = "auto"
|
|
112
|
+
testpaths = ["tests"]
|
|
113
|
+
addopts = "-ra"
|
|
114
|
+
|
|
115
|
+
[tool.coverage.run]
|
|
116
|
+
source = ["src/spinev_ble"]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
[tool.pylint.messages_control]
|
|
120
|
+
disable = [
|
|
121
|
+
"line-too-long",
|
|
122
|
+
"too-few-public-methods",
|
|
123
|
+
"too-many-instance-attributes",
|
|
124
|
+
"duplicate-code",
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
[tool.pylint.design]
|
|
128
|
+
# SpinEvCharger is one accessor per charger register, so the count tracks the
|
|
129
|
+
# device's surface rather than the class doing too much.
|
|
130
|
+
max-public-methods = 30
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Local Bluetooth LE control for Exicom Spin EV chargers.
|
|
2
|
+
|
|
3
|
+
The core of this package is a dependency free codec. It turns charger commands
|
|
4
|
+
into bytes and bytes back into values, and never touches a radio. How those
|
|
5
|
+
bytes reach the charger is up to you: bleak, an ESPHome proxy, a serial bridge,
|
|
6
|
+
or nothing at all if you only want to inspect frames.
|
|
7
|
+
|
|
8
|
+
Pure codec, no dependencies::
|
|
9
|
+
|
|
10
|
+
from spinev_ble import Command, build_control
|
|
11
|
+
|
|
12
|
+
frame = build_control(Command.START, password=0xABCDEF)
|
|
13
|
+
# send `frame` however you like
|
|
14
|
+
|
|
15
|
+
Optional convenience client, needs ``pip install spinev-ble[bleak]``::
|
|
16
|
+
|
|
17
|
+
from spinev_ble import SpinEvCharger
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
from .const import (
|
|
26
|
+
ADVERTISED_NAME_PATTERN,
|
|
27
|
+
CHARACTERISTIC_UUID,
|
|
28
|
+
DEFAULT_HISTORY_COUNT,
|
|
29
|
+
DEFAULT_MAX_CURRENT_A,
|
|
30
|
+
DEFAULT_TIMEOUT,
|
|
31
|
+
MAX_WIFI_FIELD_LEN,
|
|
32
|
+
MIN_CURRENT_A,
|
|
33
|
+
SERVICE_UUID,
|
|
34
|
+
ChargerState,
|
|
35
|
+
Command,
|
|
36
|
+
Operation,
|
|
37
|
+
Register,
|
|
38
|
+
)
|
|
39
|
+
from .exceptions import (
|
|
40
|
+
SpinEvConnectionError,
|
|
41
|
+
SpinEvError,
|
|
42
|
+
SpinEvPasswordError,
|
|
43
|
+
SpinEvProtocolError,
|
|
44
|
+
SpinEvTimeoutError,
|
|
45
|
+
SpinEvValueError,
|
|
46
|
+
)
|
|
47
|
+
from .models import ChargerStatus, ChargingSession, Frame, OcppConfig
|
|
48
|
+
from .protocol import (
|
|
49
|
+
ALARM_BITS,
|
|
50
|
+
build_control,
|
|
51
|
+
build_read,
|
|
52
|
+
build_write_float,
|
|
53
|
+
build_write_string,
|
|
54
|
+
build_write_uint,
|
|
55
|
+
decode_alarms,
|
|
56
|
+
decode_energy,
|
|
57
|
+
decode_firmware_version,
|
|
58
|
+
decode_float,
|
|
59
|
+
decode_session_record,
|
|
60
|
+
decode_string,
|
|
61
|
+
decode_uint,
|
|
62
|
+
is_history_record,
|
|
63
|
+
is_reply,
|
|
64
|
+
parse_frame,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if TYPE_CHECKING:
|
|
68
|
+
from .client import BleakClientLike, SpinEvCharger
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
__version__ = version("spinev-ble")
|
|
72
|
+
except PackageNotFoundError: # pragma: no cover - running from a source tree
|
|
73
|
+
__version__ = "0.0.0"
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"ADVERTISED_NAME_PATTERN",
|
|
77
|
+
"ALARM_BITS",
|
|
78
|
+
"CHARACTERISTIC_UUID",
|
|
79
|
+
"DEFAULT_HISTORY_COUNT",
|
|
80
|
+
"DEFAULT_MAX_CURRENT_A",
|
|
81
|
+
"DEFAULT_TIMEOUT",
|
|
82
|
+
"MAX_WIFI_FIELD_LEN",
|
|
83
|
+
"MIN_CURRENT_A",
|
|
84
|
+
"SERVICE_UUID",
|
|
85
|
+
"BleakClientLike",
|
|
86
|
+
"ChargerState",
|
|
87
|
+
"ChargerStatus",
|
|
88
|
+
"ChargingSession",
|
|
89
|
+
"Command",
|
|
90
|
+
"Frame",
|
|
91
|
+
"OcppConfig",
|
|
92
|
+
"Operation",
|
|
93
|
+
"Register",
|
|
94
|
+
"SpinEvCharger",
|
|
95
|
+
"SpinEvConnectionError",
|
|
96
|
+
"SpinEvError",
|
|
97
|
+
"SpinEvPasswordError",
|
|
98
|
+
"SpinEvProtocolError",
|
|
99
|
+
"SpinEvTimeoutError",
|
|
100
|
+
"SpinEvValueError",
|
|
101
|
+
"__version__",
|
|
102
|
+
"build_control",
|
|
103
|
+
"build_read",
|
|
104
|
+
"build_write_float",
|
|
105
|
+
"build_write_string",
|
|
106
|
+
"build_write_uint",
|
|
107
|
+
"decode_alarms",
|
|
108
|
+
"decode_energy",
|
|
109
|
+
"decode_firmware_version",
|
|
110
|
+
"decode_float",
|
|
111
|
+
"decode_session_record",
|
|
112
|
+
"decode_string",
|
|
113
|
+
"decode_uint",
|
|
114
|
+
"is_history_record",
|
|
115
|
+
"is_reply",
|
|
116
|
+
"parse_frame",
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
#: Names that live in the optional bleak backed client module.
|
|
120
|
+
_LAZY = frozenset({"BleakClientLike", "SpinEvCharger"})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def __getattr__(name: str) -> Any:
|
|
124
|
+
"""Import the optional bleak client only when it is actually asked for."""
|
|
125
|
+
if name in _LAZY:
|
|
126
|
+
try:
|
|
127
|
+
# Deliberate lazy import so the core codec needs no bleak.
|
|
128
|
+
from . import client # pylint: disable=import-outside-toplevel
|
|
129
|
+
except ImportError as err: # pragma: no cover
|
|
130
|
+
raise ImportError(
|
|
131
|
+
f"{name} needs bleak. Install it with "
|
|
132
|
+
"'pip install spinev-ble[bleak]', or use the dependency free "
|
|
133
|
+
"codec in spinev_ble.protocol instead."
|
|
134
|
+
) from err
|
|
135
|
+
return getattr(client, name)
|
|
136
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|