evsemaster 1.0.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.
- evsemaster-1.0.0/LICENSE +25 -0
- evsemaster-1.0.0/PKG-INFO +64 -0
- evsemaster-1.0.0/README.md +49 -0
- evsemaster-1.0.0/evsemaster/data_types.py +203 -0
- evsemaster-1.0.0/evsemaster/evse_protocol.py +445 -0
- evsemaster-1.0.0/pyproject.toml +27 -0
evsemaster-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Original TypeScript project:
|
|
4
|
+
Copyright (c) 2024 johnwoo-nl
|
|
5
|
+
|
|
6
|
+
Python port / adaptations:
|
|
7
|
+
Copyright (c) 2025 Rafaël Schridi @RafaelSchridi
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: evsemaster
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python implementation of the EVSEMaster App
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Rafaël Schridi
|
|
7
|
+
Author-email: rafael@schridi.nl
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Dist: pydantic (>=2.10.0,<3.0.0)
|
|
12
|
+
Requires-Dist: tzdata (>=2025.1,<2026.0)
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# evsemaster
|
|
16
|
+
|
|
17
|
+
Python client library for communicating with a EVSE chargers that use the EVSEMaster app. I've only done my testing on a Telestar EC311S, but it *should* work with any EVSE that uses the EVSEMaster app protocol.
|
|
18
|
+
I'm intenting to keep this a simple implementation, so it does not have all the features of the original TypeScript project, but it should be sufficient for basic use cases like Home Assistant integration.
|
|
19
|
+
|
|
20
|
+
This is based on the original TypeScript project by [johnwoo-nl](https://github.com/johnwoo-nl/emproto)
|
|
21
|
+
|
|
22
|
+
## Currently Implemented
|
|
23
|
+
- Get EVSE device info
|
|
24
|
+
- Get EVSE status
|
|
25
|
+
- Get EVSE charging status
|
|
26
|
+
- Start/Stop charging
|
|
27
|
+
|
|
28
|
+
## Being Implemented
|
|
29
|
+
- Create/Update charging schedule
|
|
30
|
+
- Get/Set charging limits
|
|
31
|
+
- Getting/Setting device properties like name, time, language, etc.
|
|
32
|
+
|
|
33
|
+
## Not Planned
|
|
34
|
+
- Home Assistant or MQTT integration (see [evsemaster-homeassistant](https://github.com/RafaelSchridi/evsemaster-homeassistant))
|
|
35
|
+
- Connecting to the EVSE via Bluetooth (ie. connecting the EVSE to wifi)
|
|
36
|
+
* Even though the EVSEMaster app is awful to use, using it once to connect the EVSE to wifi is sufficient for most use cases.
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
```python
|
|
41
|
+
from evsemaster.evse_protocol import SimpleEVSEProtocol
|
|
42
|
+
|
|
43
|
+
def event_callback(event, data):
|
|
44
|
+
print(f"Event: {event}, Data: {data}")
|
|
45
|
+
|
|
46
|
+
async def main():
|
|
47
|
+
evse = SimpleEVSEProtocol(
|
|
48
|
+
host="10.0.0.1", # IP address of the EVSE
|
|
49
|
+
password="123456", # 6 digit password of the EVSE
|
|
50
|
+
callback=event_callback, # Callback function for events
|
|
51
|
+
)
|
|
52
|
+
await evse.connect() # Connect to the EVSE
|
|
53
|
+
await evse.request_status() # Request the current status of the EVSE
|
|
54
|
+
await evse.start_charging() # Start charging
|
|
55
|
+
await evse.stop_charging() # Stop charging
|
|
56
|
+
await evse.disconnect() # Disconnect from the EVSE
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
There is a test script `test.py` that can be used to test the library.
|
|
60
|
+
Its a bit messy as it just prints the output while accepting commands, but it can be useful for quick testing.
|
|
61
|
+
```bash
|
|
62
|
+
poetry run python test.py 10.0.0.1 123456
|
|
63
|
+
```
|
|
64
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# evsemaster
|
|
2
|
+
|
|
3
|
+
Python client library for communicating with a EVSE chargers that use the EVSEMaster app. I've only done my testing on a Telestar EC311S, but it *should* work with any EVSE that uses the EVSEMaster app protocol.
|
|
4
|
+
I'm intenting to keep this a simple implementation, so it does not have all the features of the original TypeScript project, but it should be sufficient for basic use cases like Home Assistant integration.
|
|
5
|
+
|
|
6
|
+
This is based on the original TypeScript project by [johnwoo-nl](https://github.com/johnwoo-nl/emproto)
|
|
7
|
+
|
|
8
|
+
## Currently Implemented
|
|
9
|
+
- Get EVSE device info
|
|
10
|
+
- Get EVSE status
|
|
11
|
+
- Get EVSE charging status
|
|
12
|
+
- Start/Stop charging
|
|
13
|
+
|
|
14
|
+
## Being Implemented
|
|
15
|
+
- Create/Update charging schedule
|
|
16
|
+
- Get/Set charging limits
|
|
17
|
+
- Getting/Setting device properties like name, time, language, etc.
|
|
18
|
+
|
|
19
|
+
## Not Planned
|
|
20
|
+
- Home Assistant or MQTT integration (see [evsemaster-homeassistant](https://github.com/RafaelSchridi/evsemaster-homeassistant))
|
|
21
|
+
- Connecting to the EVSE via Bluetooth (ie. connecting the EVSE to wifi)
|
|
22
|
+
* Even though the EVSEMaster app is awful to use, using it once to connect the EVSE to wifi is sufficient for most use cases.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
```python
|
|
27
|
+
from evsemaster.evse_protocol import SimpleEVSEProtocol
|
|
28
|
+
|
|
29
|
+
def event_callback(event, data):
|
|
30
|
+
print(f"Event: {event}, Data: {data}")
|
|
31
|
+
|
|
32
|
+
async def main():
|
|
33
|
+
evse = SimpleEVSEProtocol(
|
|
34
|
+
host="10.0.0.1", # IP address of the EVSE
|
|
35
|
+
password="123456", # 6 digit password of the EVSE
|
|
36
|
+
callback=event_callback, # Callback function for events
|
|
37
|
+
)
|
|
38
|
+
await evse.connect() # Connect to the EVSE
|
|
39
|
+
await evse.request_status() # Request the current status of the EVSE
|
|
40
|
+
await evse.start_charging() # Start charging
|
|
41
|
+
await evse.stop_charging() # Stop charging
|
|
42
|
+
await evse.disconnect() # Disconnect from the EVSE
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
There is a test script `test.py` that can be used to test the library.
|
|
46
|
+
Its a bit messy as it just prints the output while accepting commands, but it can be useful for quick testing.
|
|
47
|
+
```bash
|
|
48
|
+
poetry run python test.py 10.0.0.1 123456
|
|
49
|
+
```
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from struct import unpack
|
|
3
|
+
import logging
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseSchema(BaseModel):
|
|
12
|
+
"""Base schema for all data types."""
|
|
13
|
+
|
|
14
|
+
class Config:
|
|
15
|
+
str_strip_whitespace = True
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CommandEnum(IntEnum):
|
|
19
|
+
# request = client-initiated (you send)
|
|
20
|
+
# event = anything incoming (EVSE -> you), whether it’s a reply or unsolicited
|
|
21
|
+
# response = what you send back to an event that requires it
|
|
22
|
+
|
|
23
|
+
NOT_LOGGED_IN_EVENT = 0x0001
|
|
24
|
+
LOGIN_REQUEST = 0x8002
|
|
25
|
+
LOGIN_SUCCESS_EVENT = 0x0002
|
|
26
|
+
LOGIN_CONFIRM_RESPONSE = 0x8001
|
|
27
|
+
PASSWORD_ERROR_EVENT = 0x0155
|
|
28
|
+
|
|
29
|
+
# Heading commands
|
|
30
|
+
HEADING_EVENT = 0x0003
|
|
31
|
+
HEADING_RESPONSE = 0x8003
|
|
32
|
+
|
|
33
|
+
# Status commands
|
|
34
|
+
CURRENT_STATUS_EVENT = 0x0004
|
|
35
|
+
CURRENT_STATUS_RESPONSE = 0x8004
|
|
36
|
+
CURRENT_CHARGING_STATUS_EVENT = 0x0005 # Always incoming, sent automatically by EVSE
|
|
37
|
+
CURRENT_CHARGING_STATUS_RESPONSE = 0x0006
|
|
38
|
+
|
|
39
|
+
# Charge control commands
|
|
40
|
+
CHARGE_START_REQUEST = 0x8007
|
|
41
|
+
CHARGE_START_RESPONSE = 0x0007
|
|
42
|
+
CHARGE_STOP_REQUEST = 0x8008
|
|
43
|
+
CHARGE_STOP_RESPONSE = 0x0008
|
|
44
|
+
|
|
45
|
+
#### Haven't touched yet ####
|
|
46
|
+
|
|
47
|
+
# Charge record commands
|
|
48
|
+
CURRENT_CHARGE_RECORD = 0x0009
|
|
49
|
+
UPLOAD_LOCAL_CHARGE_RECORD = 0x000A
|
|
50
|
+
REQUEST_STATUS_RECORD = 0x000D
|
|
51
|
+
REQUEST_CHARGE_STATUS_RECORD = 0x800D
|
|
52
|
+
CURRENT_CHARGE_RECORD_RESPONSE = 0x8009
|
|
53
|
+
|
|
54
|
+
# System settings commands
|
|
55
|
+
SET_AND_GET_SYSTEM_TIME = 33025
|
|
56
|
+
SET_AND_GET_SYSTEM_TIME_RESPONSE = 257
|
|
57
|
+
GET_VERSION = 33030
|
|
58
|
+
GET_VERSION_RESPONSE = 262
|
|
59
|
+
SET_AND_GET_OUTPUT_ELECTRICITY = 33031
|
|
60
|
+
SET_AND_GET_OUTPUT_ELECTRICITY_RESPONSE = 263
|
|
61
|
+
SET_AND_GET_NICK_NAME = 33032
|
|
62
|
+
SET_AND_GET_NICK_NAME_RESPONSE = 264
|
|
63
|
+
SET_AND_GET_OFF_LINE_CHARGE = 33037
|
|
64
|
+
SET_AND_GET_OFF_LINE_CHARGE_RESPONSE = 269
|
|
65
|
+
SET_AND_GET_LANGUAGE = 33039
|
|
66
|
+
SET_AND_GET_LANGUAGE_RESPONSE = 271
|
|
67
|
+
SET_AND_GET_TEMPERATURE_UNIT = 33042
|
|
68
|
+
SET_AND_GET_TEMPERATURE_UNIT_RESPONSE = 274
|
|
69
|
+
|
|
70
|
+
# Fee and strategy commands
|
|
71
|
+
SET_AND_GET_CHARGE_FEE_RESPONSE = 0x0104
|
|
72
|
+
SET_AND_GET_SERVICE_FEE_RESPONSE = 0x0105
|
|
73
|
+
SET_AND_GET_ALARM_CHARGE_STRATEGY_RESPONSE = 0x010E
|
|
74
|
+
|
|
75
|
+
# Error responses
|
|
76
|
+
PASSWORD_ERROR_RESPONSE = 341
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PlugStateEnum(IntEnum):
|
|
80
|
+
DISCONNECTED = 1
|
|
81
|
+
CONNECTED_UNLOCKED = 2
|
|
82
|
+
CONNECTED_LOCKED = 4
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CurrentStateEnum(IntEnum):
|
|
86
|
+
EVSE_FAULT = 1
|
|
87
|
+
CHARGING_FAULT_2 = 2
|
|
88
|
+
CHARGING_FAULT_3 = 3
|
|
89
|
+
WAITING_FOR_SWIPE = 10
|
|
90
|
+
WAITING_FOR_BUTTON = 11
|
|
91
|
+
NOT_CONNECTED = 12
|
|
92
|
+
READY_TO_CHARGE = 13
|
|
93
|
+
CHARGING = 14
|
|
94
|
+
COMPLETED = 15
|
|
95
|
+
COMPLETED_FULL_CHARGE = 17
|
|
96
|
+
CHARGING_RESERVATION = 20
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class EvseDeviceInfo(BaseSchema):
|
|
100
|
+
type: int
|
|
101
|
+
brand: str
|
|
102
|
+
model: str
|
|
103
|
+
hardware_version: str
|
|
104
|
+
max_power: int
|
|
105
|
+
max_amps: int
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class EvseStatus(BaseSchema):
|
|
109
|
+
line_id: int # no idea what this is, but its in the data
|
|
110
|
+
inner_temperature: float
|
|
111
|
+
outer_temperature: float
|
|
112
|
+
emergency_stop: bool
|
|
113
|
+
plug_state: PlugStateEnum
|
|
114
|
+
output_state: int
|
|
115
|
+
current_state: CurrentStateEnum
|
|
116
|
+
errors: int
|
|
117
|
+
l1_voltage: float = Field(default=0.0)
|
|
118
|
+
l1_amps: float = Field(default=0.0)
|
|
119
|
+
l2_voltage: float = Field(default=0.0)
|
|
120
|
+
l2_amps: float = Field(default=0.0)
|
|
121
|
+
l3_voltage: float = Field(default=0.0)
|
|
122
|
+
l3_amps: float = Field(default=0.0)
|
|
123
|
+
current_power: int = Field(default=0)
|
|
124
|
+
total_kwh: float = Field(default=0)
|
|
125
|
+
timestamp: datetime = Field(default_factory=datetime.now)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ChargingStatus(BaseSchema):
|
|
129
|
+
line_id: int
|
|
130
|
+
current_state: CurrentStateEnum
|
|
131
|
+
charge_id: str
|
|
132
|
+
start_type: int
|
|
133
|
+
charge_type: int
|
|
134
|
+
max_duration_minutes: int | None = None
|
|
135
|
+
max_energy_kwh: float | None = None
|
|
136
|
+
charge_param3: float | None = None
|
|
137
|
+
reservation_date: datetime
|
|
138
|
+
user_id: str
|
|
139
|
+
max_electricity: int
|
|
140
|
+
start_date: datetime
|
|
141
|
+
duration_seconds: int
|
|
142
|
+
start_kwh_counter: float
|
|
143
|
+
current_kwh_counter: float
|
|
144
|
+
charge_kwh: float
|
|
145
|
+
charge_price: float
|
|
146
|
+
fee_type: int
|
|
147
|
+
charge_fee: float
|
|
148
|
+
timestamp: datetime = Field(default_factory=datetime.now)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class DataPacket:
|
|
152
|
+
"""Class for incomind data with unpack functions."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, data: bytes):
|
|
155
|
+
if data is None or not isinstance(data, bytes):
|
|
156
|
+
raise ValueError("Data must be a non-empty bytes object")
|
|
157
|
+
if len(data) < 25:
|
|
158
|
+
raise ValueError("Data must be at least 25 bytes long")
|
|
159
|
+
# Check header
|
|
160
|
+
header = unpack(">H", data[0:2])[0]
|
|
161
|
+
if header != 0x0601:
|
|
162
|
+
raise ValueError(f"Invalid header: {header:#04x}, expected 0x0601")
|
|
163
|
+
self.command: CommandEnum = CommandEnum(unpack(">H", data[19:21])[0])
|
|
164
|
+
if self.command not in CommandEnum:
|
|
165
|
+
raise ValueError(f"Unknown command: {self.command}")
|
|
166
|
+
# how its done in TS:
|
|
167
|
+
# this.deviceSerial = buffer.toString("hex", 5, 13);
|
|
168
|
+
self.device_serial = data[5:13].hex() # Device serial number
|
|
169
|
+
self.data = data[21:] # drop all bytes before the data section
|
|
170
|
+
log.debug(self.__repr__())
|
|
171
|
+
|
|
172
|
+
def __repr__(self):
|
|
173
|
+
return f"DataPacket: {self.command.name}, s/n={self.device_serial}, len={len(self.data)}"
|
|
174
|
+
|
|
175
|
+
def length(self) -> int:
|
|
176
|
+
"""Get the length of the data."""
|
|
177
|
+
return len(self.data)
|
|
178
|
+
|
|
179
|
+
def get_string(self, offset: int, length: int = 1) -> str:
|
|
180
|
+
"""Read string from byte data."""
|
|
181
|
+
end = offset + length
|
|
182
|
+
string_data = self.data[offset:end]
|
|
183
|
+
null_pos = string_data.find(b"\x00")
|
|
184
|
+
if null_pos >= 0:
|
|
185
|
+
string_data = string_data[:null_pos]
|
|
186
|
+
return string_data.decode("ascii", errors="ignore")
|
|
187
|
+
|
|
188
|
+
def get_buffer(self, offset: int, length: int = 1) -> bytes:
|
|
189
|
+
"""Read buffer from byte data."""
|
|
190
|
+
end = offset + length
|
|
191
|
+
return self.data[offset:end]
|
|
192
|
+
|
|
193
|
+
def get_int(self, offset: int, length: int = 1) -> int:
|
|
194
|
+
"""Read integer from byte data."""
|
|
195
|
+
end = offset + length
|
|
196
|
+
return int.from_bytes(self.data[offset:end], byteorder="big", signed=False)
|
|
197
|
+
|
|
198
|
+
def read_temperature(self, offset: int) -> float:
|
|
199
|
+
"""Read temperature from byte data."""
|
|
200
|
+
temp = self.get_int(offset, 2)
|
|
201
|
+
if temp == 0xFFFF:
|
|
202
|
+
return -1.0
|
|
203
|
+
return round((temp - 20000) / 100, 1)
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Simple EVSE protocol implementation for Home Assistant integration."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import struct
|
|
5
|
+
import socket
|
|
6
|
+
from typing import Dict, Any, Optional
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
import asyncio
|
|
9
|
+
import zoneinfo
|
|
10
|
+
from .data_types import CommandEnum, DataPacket, EvseDeviceInfo, EvseStatus, ChargingStatus, CurrentStateEnum
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SimpleEVSEProtocol:
|
|
16
|
+
"""Simple implementation of EVSE protocol for HA integration."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, host: str, password: str, event_callback: callable = None):
|
|
19
|
+
"""Initialize protocol handler."""
|
|
20
|
+
self.host = host
|
|
21
|
+
self.password = password
|
|
22
|
+
self._event_callback = event_callback
|
|
23
|
+
self.listen_port = 28376 # Port to listen for incoming datagrams
|
|
24
|
+
self.send_port = 7248 # Default port to send to (will be updated by discovery)
|
|
25
|
+
self.serial_number = "00000000" # Placeholder for device serial number
|
|
26
|
+
self.user_id = "evsemaster_python" # Do all actions as this "user"
|
|
27
|
+
self._listen_socket: Optional[socket.socket] = None
|
|
28
|
+
self._send_socket: Optional[socket.socket] = None
|
|
29
|
+
self._logged_in = False
|
|
30
|
+
self._status: Optional[EvseStatus] = None
|
|
31
|
+
self._device_info: Optional[EvseDeviceInfo] = None
|
|
32
|
+
self._charging_status: Optional[ChargingStatus] = None
|
|
33
|
+
self._discovery_running = False
|
|
34
|
+
|
|
35
|
+
async def send_packet(self, data: bytes):
|
|
36
|
+
"""Send a packet to the EVSE."""
|
|
37
|
+
if not self._send_socket:
|
|
38
|
+
log.error("Send socket is not initialized")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
self._send_socket.sendto(data, (self.host, self.send_port))
|
|
43
|
+
except Exception as e:
|
|
44
|
+
log.error("Failed to send packet: %s", e)
|
|
45
|
+
|
|
46
|
+
async def connect(self) -> bool:
|
|
47
|
+
"""Connect to EVSE and start discovery."""
|
|
48
|
+
try:
|
|
49
|
+
# Create listen socket for receiving datagrams from EVSE
|
|
50
|
+
self._listen_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
51
|
+
self._listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
52
|
+
self._listen_socket.bind(("0.0.0.0", self.listen_port))
|
|
53
|
+
self._listen_socket.settimeout(10.0)
|
|
54
|
+
|
|
55
|
+
# Create send socket for sending commands to EVSE
|
|
56
|
+
self._send_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
57
|
+
self._send_socket.settimeout(10.0)
|
|
58
|
+
|
|
59
|
+
log.info(
|
|
60
|
+
"Connected to EVSE at %s, listening on port %d",
|
|
61
|
+
self.host,
|
|
62
|
+
self.listen_port,
|
|
63
|
+
)
|
|
64
|
+
return True
|
|
65
|
+
except Exception as err:
|
|
66
|
+
log.error("Failed to connect: %s", err)
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
async def disconnect(self):
|
|
70
|
+
"""Disconnect from EVSE."""
|
|
71
|
+
self._discovery_running = False
|
|
72
|
+
if self._listen_socket:
|
|
73
|
+
self._listen_socket.close()
|
|
74
|
+
self._listen_socket = None
|
|
75
|
+
if self._send_socket:
|
|
76
|
+
self._send_socket.close()
|
|
77
|
+
self._send_socket = None
|
|
78
|
+
self._logged_in = False
|
|
79
|
+
|
|
80
|
+
async def login(self) -> bool:
|
|
81
|
+
"""Login to EVSE."""
|
|
82
|
+
if self._logged_in:
|
|
83
|
+
log.info("Already logged in to EVSE,reconnecting")
|
|
84
|
+
await self.disconnect()
|
|
85
|
+
|
|
86
|
+
if not self._send_socket or not self._listen_socket:
|
|
87
|
+
if not await self.connect():
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Start discovery to find the correct port
|
|
92
|
+
await self._discover_evse_port()
|
|
93
|
+
|
|
94
|
+
await self.send_packet(self._build_packet(CommandEnum.LOGIN_REQUEST))
|
|
95
|
+
|
|
96
|
+
# Try to receive response
|
|
97
|
+
try:
|
|
98
|
+
data, addr = self._listen_socket.recvfrom(1024)
|
|
99
|
+
packet = DataPacket(data) # Parse incoming data packet
|
|
100
|
+
if self._parse_login_response(packet):
|
|
101
|
+
# If login response is successful, send confirm
|
|
102
|
+
await self.send_packet(self._build_packet(CommandEnum.LOGIN_CONFIRM_RESPONSE))
|
|
103
|
+
data, addr = self._listen_socket.recvfrom(1024)
|
|
104
|
+
data_packet = DataPacket(data) # Parse confirm response
|
|
105
|
+
self._parse_login_response(data_packet)
|
|
106
|
+
self._logged_in = True
|
|
107
|
+
if data_packet.device_serial:
|
|
108
|
+
self.serial_number = data_packet.device_serial
|
|
109
|
+
log.info("Login successful")
|
|
110
|
+
# Start listener loop in background
|
|
111
|
+
asyncio.create_task(self.listener_loop())
|
|
112
|
+
return True
|
|
113
|
+
except socket.timeout:
|
|
114
|
+
log.warning("Login timeout")
|
|
115
|
+
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
except Exception as err:
|
|
119
|
+
log.exception("Failed to login: %s", err)
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
def send_event(self, event_type: str, data: Any):
|
|
123
|
+
"""Handle events from the EVSE."""
|
|
124
|
+
if self._event_callback:
|
|
125
|
+
try:
|
|
126
|
+
self._event_callback(event_type, data)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
log.error(f"Error in event callback: {e}")
|
|
129
|
+
|
|
130
|
+
async def listener_loop(self):
|
|
131
|
+
"""Run the listener loop to handle incoming datagrams."""
|
|
132
|
+
if not self._listen_socket:
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
log.info("Starting listener loop on port %d", self.listen_port)
|
|
136
|
+
while self._logged_in:
|
|
137
|
+
try:
|
|
138
|
+
data, addr = self._listen_socket.recvfrom(1024)
|
|
139
|
+
try:
|
|
140
|
+
data_packet = DataPacket(data) # Parse incoming data packet and slices the preamble
|
|
141
|
+
except ValueError as e:
|
|
142
|
+
log.error(f"Invalid data packet received: {e}")
|
|
143
|
+
continue
|
|
144
|
+
match data_packet.command:
|
|
145
|
+
case CommandEnum.HEADING_EVENT:
|
|
146
|
+
# response to keepalive, need to send back HEADING_RESPONSE
|
|
147
|
+
await self.send_packet(self._build_packet(CommandEnum.HEADING_RESPONSE))
|
|
148
|
+
case CommandEnum.NOT_LOGGED_IN_EVENT:
|
|
149
|
+
# not logged in, try to login
|
|
150
|
+
self._logged_in = False
|
|
151
|
+
log.warning("Logged out by EVSE.")
|
|
152
|
+
case CommandEnum.LOGIN_SUCCESS_EVENT:
|
|
153
|
+
# Handle login response
|
|
154
|
+
if self._parse_login_response(data_packet):
|
|
155
|
+
log.info("Login response received, sending confirm")
|
|
156
|
+
await self.send_packet(self._build_packet(CommandEnum.LOGIN_CONFIRM_RESPONSE))
|
|
157
|
+
case CommandEnum.CURRENT_STATUS_EVENT:
|
|
158
|
+
log.debug(self._parse_status_response(data_packet))
|
|
159
|
+
# Do we need to send a response?
|
|
160
|
+
await self.send_packet(self._build_packet(CommandEnum.CURRENT_STATUS_RESPONSE))
|
|
161
|
+
self.send_event(EvseStatus.__name__, self._status)
|
|
162
|
+
case CommandEnum.CURRENT_CHARGING_STATUS_EVENT:
|
|
163
|
+
log.debug(self._parse_ac_charging_status(data_packet))
|
|
164
|
+
self.send_event(ChargingStatus.__name__, self._charging_status)
|
|
165
|
+
case CommandEnum.UPLOAD_LOCAL_CHARGE_RECORD:
|
|
166
|
+
pass
|
|
167
|
+
case _:
|
|
168
|
+
log.warning("Unhandled command: %s", data_packet.command.name)
|
|
169
|
+
await asyncio.sleep(0.1) # Avoid busy loop
|
|
170
|
+
except socket.timeout:
|
|
171
|
+
log.warning("Listener socket timeout, retrying...")
|
|
172
|
+
continue
|
|
173
|
+
await asyncio.sleep(1) # Give the system some time to recover
|
|
174
|
+
except Exception as e:
|
|
175
|
+
log.error(f"Listener loop error: {e}, restarting.")
|
|
176
|
+
await asyncio.sleep(1) # Give the system some time to recover
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
async def _discover_evse_port(self) -> bool:
|
|
180
|
+
"""Discover EVSE port by listening for incoming datagrams."""
|
|
181
|
+
if not self._listen_socket:
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
log.info("Starting EVSE port discovery on %s", self.host)
|
|
185
|
+
|
|
186
|
+
# Send a discovery packet to the default port to trigger a response
|
|
187
|
+
discovery_packet = self._build_packet(CommandEnum.LOGIN_REQUEST)
|
|
188
|
+
if self._send_socket:
|
|
189
|
+
self._send_socket.sendto(discovery_packet, (self.host, self.send_port))
|
|
190
|
+
|
|
191
|
+
# Listen for any incoming datagram from our target EVSE
|
|
192
|
+
for _ in range(5): # Try for up to 5 attempts
|
|
193
|
+
try:
|
|
194
|
+
data, addr = self._listen_socket.recvfrom(1024)
|
|
195
|
+
if addr[0] == self.host:
|
|
196
|
+
# Found a datagram from our EVSE, update the port
|
|
197
|
+
discovered_port = addr[1]
|
|
198
|
+
if discovered_port != self.send_port:
|
|
199
|
+
log.info(
|
|
200
|
+
"Discovered EVSE port: %d (was using %d)",
|
|
201
|
+
discovered_port,
|
|
202
|
+
self.send_port,
|
|
203
|
+
)
|
|
204
|
+
self.send_port = discovered_port
|
|
205
|
+
return True
|
|
206
|
+
except socket.timeout:
|
|
207
|
+
# Try sending another discovery packet
|
|
208
|
+
if self._send_socket:
|
|
209
|
+
self._send_socket.sendto(discovery_packet, (self.host, self.send_port))
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
log.warning("Could not discover EVSE port, using default %d", self.send_port)
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
async def request_status(self) -> Dict[str, Any]:
|
|
216
|
+
"""Get EVSE status."""
|
|
217
|
+
if not self._logged_in or not self._send_socket:
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
# Send status request
|
|
222
|
+
await self.send_packet(self._build_packet(CommandEnum.CURRENT_STATUS_EVENT))
|
|
223
|
+
return True
|
|
224
|
+
|
|
225
|
+
except Exception as err:
|
|
226
|
+
log.error("Failed to get status: %s", err)
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
async def start_charging(
|
|
230
|
+
self, max_amps: int = 16, start_date: datetime = datetime.now(), duration_minutes: int = 65535
|
|
231
|
+
) -> bool:
|
|
232
|
+
"""Start charging."""
|
|
233
|
+
if not self._logged_in or not self._send_socket:
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
if self._status and self._status.current_state == CurrentStateEnum.CHARGING_RESERVATION:
|
|
238
|
+
log.warning("Start charge send while a reservation is active, cancelling reservation first")
|
|
239
|
+
await self.stop_charging()
|
|
240
|
+
extra_payload = bytearray(47)
|
|
241
|
+
|
|
242
|
+
# Line ID (seems to be always one 1, are there any devices with multiple lines?)
|
|
243
|
+
struct.pack_into(">B", extra_payload, 0, 1)
|
|
244
|
+
# User ID (16 bytes, ASCII encoded)
|
|
245
|
+
struct.pack_into(">16s", extra_payload, 1, self.user_id.encode("ascii")[:16])
|
|
246
|
+
# Charge ID (16 bytes, ASCII encoded)
|
|
247
|
+
struct.pack_into(">16s", extra_payload, 17, start_date.strftime("%Y%m%d%H%M").encode("ascii")[:16])
|
|
248
|
+
# Reservation: 0 for now, 1 if future reservation
|
|
249
|
+
struct.pack_into(">B", extra_payload, 33, 0 if datetime.now() > start_date else 1)
|
|
250
|
+
# Reservation date (current time in Shanghai epoch)
|
|
251
|
+
struct.pack_into(">I", extra_payload, 34, self._datetime_to_shanghai_epoch(start_date))
|
|
252
|
+
# Start type (always 1)
|
|
253
|
+
struct.pack_into(">B", extra_payload, 38, 1)
|
|
254
|
+
# Charge type (always 1)
|
|
255
|
+
struct.pack_into(">B", extra_payload, 39, 1)
|
|
256
|
+
# Max duration (65535 = highest possible, unlimited)
|
|
257
|
+
struct.pack_into(">H", extra_payload, 40, duration_minutes)
|
|
258
|
+
# Max energy (65535 = highest possible, unlimited)
|
|
259
|
+
struct.pack_into(">H", extra_payload, 42, 65535)
|
|
260
|
+
# Charge param 3 (always 65535)
|
|
261
|
+
struct.pack_into(">H", extra_payload, 44, 65535)
|
|
262
|
+
# Max electricity in amps
|
|
263
|
+
struct.pack_into(">B", extra_payload, 46, max_amps)
|
|
264
|
+
|
|
265
|
+
packet = self._build_packet(CommandEnum.CHARGE_START_REQUEST, extra_payload)
|
|
266
|
+
await self.send_packet(packet)
|
|
267
|
+
log.info("Sent charge start command")
|
|
268
|
+
return True
|
|
269
|
+
except Exception as err:
|
|
270
|
+
log.error("Failed to start charging: %s", err)
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
def _datetime_to_shanghai_epoch(self, dt: datetime) -> int:
|
|
274
|
+
"""
|
|
275
|
+
Convert datetime to Shanghai timestamp.
|
|
276
|
+
"""
|
|
277
|
+
log.debug(f"Converting time {dt.timestamp()} in {dt.tzinfo} to Shanghai epoch")
|
|
278
|
+
shanghai_tz = zoneinfo.ZoneInfo("Asia/Shanghai")
|
|
279
|
+
if dt.tzinfo is None:
|
|
280
|
+
dt = dt.replace(tzinfo=shanghai_tz)
|
|
281
|
+
elif dt.tzinfo.zone != "Asia/Shanghai":
|
|
282
|
+
dt = dt.astimezone(shanghai_tz)
|
|
283
|
+
log.debug(f"Converted time is now {dt.timestamp()} in {dt.tzinfo}")
|
|
284
|
+
return int(dt.timestamp())
|
|
285
|
+
|
|
286
|
+
async def stop_charging(self) -> bool:
|
|
287
|
+
"""Stop charging."""
|
|
288
|
+
if not self._logged_in or not self._send_socket:
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
extra_payload = bytearray(1) # Extra payload for charge stop
|
|
293
|
+
extra_payload[0] = 1 # Port to stop charging on
|
|
294
|
+
packet = self._build_packet(CommandEnum.CHARGE_STOP_REQUEST, extra_payload)
|
|
295
|
+
await self.send_packet(packet)
|
|
296
|
+
log.info("Sent charge stop command")
|
|
297
|
+
return True
|
|
298
|
+
except Exception as err:
|
|
299
|
+
log.error("Failed to stop charging: %s", err)
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
def _build_packet(self, cmd: CommandEnum, payload: bytes = b"") -> bytes:
|
|
303
|
+
"""Generic method to build a packet with given command and payload."""
|
|
304
|
+
packet = bytearray(25 + len(payload))
|
|
305
|
+
|
|
306
|
+
# Header (0x0601)
|
|
307
|
+
struct.pack_into(">H", packet, 0, 0x0601)
|
|
308
|
+
# Length
|
|
309
|
+
struct.pack_into(">H", packet, 2, len(packet))
|
|
310
|
+
# Key type
|
|
311
|
+
packet[4] = 0x00
|
|
312
|
+
# Device serial (8 bytes) - use zeros for now
|
|
313
|
+
# Password (6 bytes)
|
|
314
|
+
if self.password:
|
|
315
|
+
password_bytes = self.password.encode("ascii")[:6]
|
|
316
|
+
else:
|
|
317
|
+
password_bytes = b"\x00\x00\x00\x00\x00\x00"
|
|
318
|
+
packet[13 : 13 + len(password_bytes)] = password_bytes
|
|
319
|
+
# Command
|
|
320
|
+
struct.pack_into(">H", packet, 19, cmd)
|
|
321
|
+
# Payload
|
|
322
|
+
packet[21 : 21 + len(payload)] = payload
|
|
323
|
+
# Checksum
|
|
324
|
+
checksum = sum(packet[:-4]) % 0xFFFF
|
|
325
|
+
struct.pack_into(">H", packet, len(packet) - 4, checksum)
|
|
326
|
+
# Tail (0x0f02)
|
|
327
|
+
struct.pack_into(">H", packet, len(packet) - 2, 0x0F02)
|
|
328
|
+
|
|
329
|
+
return bytes(packet)
|
|
330
|
+
|
|
331
|
+
def _parse_login_response(self, data: DataPacket) -> bool:
|
|
332
|
+
"""Parse login response."""
|
|
333
|
+
try:
|
|
334
|
+
if data.command == CommandEnum.LOGIN_SUCCESS_EVENT:
|
|
335
|
+
# Login response - extract device info
|
|
336
|
+
self._parse_device_info(data)
|
|
337
|
+
return True
|
|
338
|
+
elif data.command == CommandEnum.PASSWORD_ERROR_EVENT:
|
|
339
|
+
# Password error
|
|
340
|
+
log.error("Password error received")
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
return False
|
|
344
|
+
|
|
345
|
+
except Exception as err:
|
|
346
|
+
log.error("Failed to parse login response: %s", err)
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
def _parse_device_info(self, data: DataPacket):
|
|
350
|
+
"""Parse device information from login response."""
|
|
351
|
+
try:
|
|
352
|
+
if data.length() < 25:
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
self._device_info = EvseDeviceInfo(
|
|
356
|
+
type=data.get_int(0, 1),
|
|
357
|
+
brand=data.get_string(1, 16),
|
|
358
|
+
model=data.get_string(17, 16),
|
|
359
|
+
hardware_version=data.get_string(33, 16),
|
|
360
|
+
max_power=data.get_int(49, 4),
|
|
361
|
+
max_amps=data.get_int(53, 1),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
except Exception as err:
|
|
365
|
+
log.error("Failed to parse device info: %s", err)
|
|
366
|
+
|
|
367
|
+
def _parse_status_response(self, data: DataPacket):
|
|
368
|
+
"""Parse status response."""
|
|
369
|
+
try:
|
|
370
|
+
if data.length() < 33:
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
self._status = EvseStatus(
|
|
374
|
+
line_id=data.get_int(0, 1),
|
|
375
|
+
l1_voltage=data.get_int(1, 2) / 10,
|
|
376
|
+
l1_amps=data.get_int(3, 2) / 100,
|
|
377
|
+
current_power=data.get_int(5, 4),
|
|
378
|
+
total_kwh=data.get_int(9, 4) / 100,
|
|
379
|
+
inner_temperature=data.read_temperature(13),
|
|
380
|
+
outer_temperature=data.read_temperature(15),
|
|
381
|
+
emergency_stop=data.get_int(17, 1),
|
|
382
|
+
plug_state=data.get_int(18, 1),
|
|
383
|
+
output_state=data.get_int(19, 1),
|
|
384
|
+
current_state=data.get_int(20, 1),
|
|
385
|
+
errors=data.get_int(21, 4),
|
|
386
|
+
l2_voltage=data.get_int(25, 2) / 10,
|
|
387
|
+
l2_amps=data.get_int(27, 2) / 100,
|
|
388
|
+
l3_voltage=data.get_int(29, 2) / 10,
|
|
389
|
+
l3_amps=data.get_int(31, 2) / 100,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
return self._status
|
|
393
|
+
|
|
394
|
+
except Exception as err:
|
|
395
|
+
log.error("Failed to parse status response: %s", err)
|
|
396
|
+
|
|
397
|
+
def _parse_ac_charging_status(self, data: DataPacket):
|
|
398
|
+
"""Parse AC charging status response."""
|
|
399
|
+
try:
|
|
400
|
+
if data.length() < 25:
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
self._charging_status = ChargingStatus(
|
|
404
|
+
line_id=data.get_int(0, 1),
|
|
405
|
+
current_state=data.get_int(1, 1),
|
|
406
|
+
charge_id=data.get_string(2, 16),
|
|
407
|
+
start_type=data.get_int(18, 1),
|
|
408
|
+
charge_type=data.get_int(19, 1),
|
|
409
|
+
max_duration_minutes=None if data.get_int(20, 2) == 65535 else data.get_int(20, 2),
|
|
410
|
+
max_energy_kwh=None if data.get_int(22, 2) == 65535 else data.get_int(22, 2) * 0.01,
|
|
411
|
+
charge_param3=None if data.get_int(24, 2) == 65535 else data.get_int(24, 2) * 0.01,
|
|
412
|
+
reservation_date=datetime.fromtimestamp(data.get_int(26, 4)),
|
|
413
|
+
user_id=data.get_string(30, 16),
|
|
414
|
+
max_electricity=data.get_int(46, 1),
|
|
415
|
+
start_date=datetime.fromtimestamp(data.get_int(47, 4)),
|
|
416
|
+
duration_seconds=data.get_int(51, 4),
|
|
417
|
+
start_kwh_counter=data.get_int(55, 4) * 0.01,
|
|
418
|
+
current_kwh_counter=data.get_int(59, 4) * 0.01,
|
|
419
|
+
charge_kwh=data.get_int(63, 4) * 0.01,
|
|
420
|
+
charge_price=data.get_int(67, 4) * 0.01,
|
|
421
|
+
fee_type=data.get_int(71, 1),
|
|
422
|
+
charge_fee=data.get_int(72, 2) * 0.01,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
return self._charging_status
|
|
426
|
+
|
|
427
|
+
except Exception as err:
|
|
428
|
+
log.error("Failed to parse AC charging status: %s", err)
|
|
429
|
+
|
|
430
|
+
def get_latest_device_info(self) -> Optional[EvseDeviceInfo]:
|
|
431
|
+
"""Get the latest device info."""
|
|
432
|
+
return self._device_info
|
|
433
|
+
|
|
434
|
+
def get_latest_status(self) -> Optional[EvseStatus]:
|
|
435
|
+
"""Get the latest EVSE status."""
|
|
436
|
+
return self._status
|
|
437
|
+
|
|
438
|
+
def get_latest_charging_status(self) -> Optional[ChargingStatus]:
|
|
439
|
+
"""Get the latest charging status."""
|
|
440
|
+
return self._charging_status
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def is_logged_in(self) -> bool:
|
|
444
|
+
"""Check if logged in."""
|
|
445
|
+
return self._logged_in
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "evsemaster"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Python implementation of the EVSEMaster App"
|
|
5
|
+
authors = [
|
|
6
|
+
"Rafaël Schridi <rafael@schridi.nl>"
|
|
7
|
+
]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
packages = [
|
|
11
|
+
{ include = "evsemaster" }
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[tool.poetry.dependencies]
|
|
15
|
+
python = ">=3.13"
|
|
16
|
+
pydantic = "^2.10.0"
|
|
17
|
+
tzdata = "^2025.1"
|
|
18
|
+
|
|
19
|
+
[tool.poetry.group.dev.dependencies]
|
|
20
|
+
ruff = "^0.12.8"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
24
|
+
build-backend = "poetry.core.masonry.api"
|
|
25
|
+
|
|
26
|
+
[tool.ruff]
|
|
27
|
+
line-length = 120
|