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.
@@ -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