Skip to content

Commit 92bcb8b

Browse files
committed
Added support for the other transactions
1 parent 84624db commit 92bcb8b

2 files changed

Lines changed: 204 additions & 73 deletions

File tree

README.md

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,33 +33,44 @@ pip install -e .
3333

3434
## 🎮 Quick Start
3535

36-
### Usage
36+
### Usage (commands)
3737

3838
```python
3939
import logging
4040
from py_incharge import InChargesend_remote_start
4141

4242
# Optional: logging.basicConfig(level=logging.INFO)
4343
client = InCharge(email="your@email.com", password="your_password", subscription_key="your_subscription_key")
44-
client.login()
44+
client.login() # Required for other calls to work, takes about 5-10 seconds.
4545

4646
# Charge your car like it's 2025
47-
client.start_remote_transaction(station_name="EVB-12345678", rfid="123456abcdef")
47+
client.start_transaction(station_name="EVB-12345678", rfid="123456abcdef")
48+
49+
# Other commands
50+
client.unlock_connector(station_name="EVB-12345678")
51+
client.stop_transaction(station_name="EVB-12345678", transaction_id=1)
52+
client.set_light_intensity(station_name="EVB-12345678", "90")
53+
client.trigger_status_notification(station_name="EVB-12345678")
54+
client.reset(mode="Soft") # Careful with this one
4855
```
4956

5057
## 🌟 How It Works
5158

52-
1. **Login**: Uses Selenium to authenticate with Vattenfall's portal
53-
2. **Get Tokens**: Retrieves bearer tokens and command IDs
54-
3. **WebSocket Connection**: Establishes a real-time connection
55-
4. **Send Commands**: Sends remote start commands to your charging station
56-
5. **Profit**: Your car starts charging! 🎉
59+
1. **Login**: Uses Selenium to authenticate with Vattenfall InCharge. The authentication token is inferred and used for subsequent calls. This is the only step that uses selenium.
60+
2. **Send Commands**: Now you can send commands like `start_transaction(...)` or `unlock_connector(...)`. For every call roughly the following steps are executed:
61+
62+
- A new ticket ID is requested.
63+
- Via a websocket connection this ticket is validated.
64+
- The command with specific parameters (`station_name`, `connector_id`, `transaction_id`, etc.) is send to the websocket.
65+
- The reponse status is validated.
66+
67+
3. **Profit**: Your car starts (or stops, or something else) charging! 🎉
5768

5869
## 🚨 Important Notes
5970

6071
- **Chrome Required**: This package uses Chrome for authentication
6172
- **Credentials**: Keep your credentials safe and never commit them to version control
62-
- **Rate Limits**: Don't spam the API (be nice to the servers)
73+
- **Rate Limits**: Don't spam the API (be nice to the servers), especially take time between commands!
6374
- **Testing**: Always test in a safe environment first
6475

6576
## 🤝 Contributing

src/py_incharge/client.py

Lines changed: 184 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import enum
12
import json
23
import logging
34
import time
4-
from typing import Optional
5+
from typing import Literal, Optional
56

67
import requests
78
import websocket
@@ -11,6 +12,25 @@
1112
from py_incharge.utils import find_element_through_shadow
1213

1314

15+
class Command(enum.Enum):
16+
unlock_connector = "UnlockConnector"
17+
start_transaction = "Remote start transaction"
18+
stop_transaction = "Remote Stop Transaction"
19+
set_light_intensity = "Set Light intensity"
20+
change_availability = "Change availability"
21+
reset = "Reset"
22+
trigger_status_notification = "TriggerMessage StatusNotificat"
23+
24+
25+
class WebsocketMessageType(enum.Enum):
26+
ticket_auth = "TICKET_AUTH"
27+
custom_command = "CUSTOM_COMMAND"
28+
response = "RESPONSE"
29+
sent = "SENT"
30+
ping = "PING"
31+
error = "ERROR"
32+
33+
1434
class InCharge:
1535
AZURE_BASE_URL = "https://businessspecificapimanglobal.azure-api.net"
1636

@@ -92,16 +112,168 @@ def login(self):
92112
driver.quit()
93113
logging.info("Login successful, bearer token obtained")
94114

95-
def get_ticket(self) -> str:
115+
@staticmethod
116+
def requires_login(method):
117+
"""Decorator to ensure the user is logged in before executing a method."""
118+
119+
def wrapper(self, *args, **kwargs):
120+
if not self.bearer_token:
121+
raise ValueError("Must login first (call 'client.login()')")
122+
return method(self, *args, **kwargs)
123+
124+
return wrapper
125+
126+
@requires_login
127+
def unlock_connector(self, station_name, connector_id: int = 1):
128+
"""This will unlock your EV charger."""
129+
return self._send_command_via_websocket(
130+
self._get_command_id(station_name, Command.unlock_connector),
131+
station_name,
132+
{"example-number-parameter": connector_id},
133+
expected_status="Unlocked",
134+
)
135+
136+
@requires_login
137+
def start_transaction(self, station_name: str, rfid: str, connector_id: int = 1):
138+
"""This will turn on your EV charger."""
139+
return self._send_command_via_websocket(
140+
self._get_command_id(station_name, Command.start_transaction),
141+
station_name,
142+
{"connectorId": connector_id, "idTag": rfid},
143+
expected_status="Accepted",
144+
)
145+
146+
@requires_login
147+
def set_light_intensity(
148+
self,
149+
station_name: str,
150+
intensity: Literal["0", "10", "25", "50", "75", "90", "100"],
151+
):
152+
"""This will set the light intensity of the EV charger."""
153+
return self._send_command_via_websocket(
154+
self._get_command_id(station_name, Command.set_light_intensity),
155+
station_name,
156+
{"example-enum-parameter": intensity},
157+
expected_status="Accepted",
158+
)
159+
160+
@requires_login
161+
def stop_transaction(self, station_name: str, transaction_id: int = 1):
162+
"""This will turn off your EV charger."""
163+
return self._send_command_via_websocket(
164+
self._get_command_id(station_name, Command.stop_transaction),
165+
station_name,
166+
{"transactionId": transaction_id},
167+
expected_status="Accepted",
168+
)
169+
170+
@requires_login
171+
def change_availability(
172+
self,
173+
station_name: str,
174+
availability: Literal["Operative", "Inoperative"],
175+
connector_id: int = 1,
176+
):
177+
"""This will change the availability of the EV charger."""
178+
return self._send_command_via_websocket(
179+
self._get_command_id(station_name, Command.change_availability),
180+
station_name,
181+
{"connectorId": connector_id, "availability": availability},
182+
expected_status="Accepted",
183+
)
184+
185+
@requires_login
186+
def reset(self, station_name: str, mode: Literal["Soft", "Hard"] = "Soft"):
187+
"""This will reset the EV charger."""
188+
return self._send_command_via_websocket(
189+
self._get_command_id(station_name, Command.reset),
190+
station_name,
191+
{"typeOfReset": mode},
192+
expected_status="Accepted",
193+
)
194+
195+
@requires_login
196+
def trigger_status_notification(self, station_name: str, connector_id: int = 1):
197+
"""This will trigger a status notification for the EV charger."""
198+
return self._send_command_via_websocket(
199+
self._get_command_id(station_name, Command.trigger_status_notification),
200+
station_name,
201+
{"connectorId": connector_id},
202+
expected_status="Accepted",
203+
)
204+
205+
@requires_login
206+
def _send_command_via_websocket(
207+
self, command_id: str, station_name: str, parameters: dict, expected_status: str
208+
) -> bool:
209+
"""
210+
Sends a command via websocket to the InCharge API and waits for a response.
211+
This method is used for various commands like unlocking a connector, starting a transaction, etc.
212+
It also checks the response status to determine if the command was accepted or rejected.
213+
214+
It does the following:
215+
1. Connects to the InCharge websocket server.
216+
2. Requests a new ticket id.
217+
3. Sends a ticket authentication message to the websocket.
218+
4. Sends a custom command message with the specified command ID, station name, and parameters.
219+
5. Waits for a response from the websocket and checks the status of the response.
220+
"""
221+
222+
logging.info("Connecting to websocket")
223+
ws = websocket.create_connection(self.WEBSOCKET_URL)
224+
225+
try:
226+
ticket_auth_msg = {
227+
"type": WebsocketMessageType.ticket_auth.value,
228+
"id": self._get_new_ticket_id(),
229+
}
230+
ws.send(json.dumps(ticket_auth_msg))
231+
232+
logging.info(f"Sent ticket authentication to websocket: {ticket_auth_msg}")
233+
logging.info(f"Received message from websocket: {ws.recv()}")
234+
time.sleep(1)
235+
236+
custom_command_msg = {
237+
"type": WebsocketMessageType.custom_command.value,
238+
"commandId": command_id,
239+
"stations": [station_name],
240+
"parameters": parameters,
241+
}
242+
ws.send(json.dumps(custom_command_msg))
243+
244+
logging.info(f"Sent command to websocket: {custom_command_msg}")
245+
246+
while True:
247+
msg = ws.recv()
248+
logging.info(f"Received message from websocket: {msg}")
249+
250+
msg_json = json.loads(msg)
251+
if msg_json.get("type") == WebsocketMessageType.response.value:
252+
payload = json.loads(msg_json.get("payload", "{}"))
253+
254+
status = payload.get("status")
255+
if status == expected_status:
256+
logging.info(f"Command accepted: {status}")
257+
return True
258+
elif status == "Rejected":
259+
logging.error(f"Command rejected: {status}")
260+
return False
261+
elif msg_json.get("type") == WebsocketMessageType.error.value:
262+
logging.error(
263+
f"Error received from websocket: {msg_json.get('payload')}"
264+
)
265+
return False
266+
finally:
267+
ws.close()
268+
269+
@requires_login
270+
def _get_new_ticket_id(self) -> str:
96271
"""
97272
Before starting a remote transaction, a ticket ID must be obtained.
98273
This ticket is used to authenticate the websocket connection and is required
99274
to start a remote transaction. This function sends a POST request to the
100275
InCharge API to obtain a ticket id.
101276
"""
102-
if not self.bearer_token:
103-
raise ValueError("Must login first before getting ticket")
104-
105277
response = requests.post(
106278
InCharge.TICKET_URL,
107279
headers={
@@ -112,21 +284,19 @@ def get_ticket(self) -> str:
112284
json={},
113285
)
114286
if response.status_code == 200:
115-
print("Ticket request failed:", response.status_code, response.text)
287+
logging.error("Ticket request failed:", response.status_code, response.text)
116288
raise ValueError("Failed to get ticket from API")
117289

118290
return response.text.strip().strip('"')
119291

120-
def get_remote_start_command_id(self, station_name: str) -> str:
292+
@requires_login
293+
def _get_command_id(self, station_name: str, command: Command) -> str:
121294
"""
122295
Nobody knows why, but the command ID is not static and must be fetched
123296
from the InCharge API every time before starting a remote transaction.
124297
This function sends a GET request to the InCharge API to retrieve the command id
125298
for the remote start transaction command for the specified station.
126299
"""
127-
if not self.bearer_token:
128-
raise ValueError("Must login first before getting command ID")
129-
130300
response = requests.get(
131301
InCharge.COMMAND_ID_URL.format(station_name=station_name),
132302
headers={
@@ -139,58 +309,8 @@ def get_remote_start_command_id(self, station_name: str) -> str:
139309
print("Command ID request failed:", response.status_code, response.text)
140310
raise ValueError("Failed to get command ID from API")
141311

142-
for command in response.json():
143-
if command["details"]["name"] == "Remote start transaction":
144-
return command["commandId"]
312+
for command_info in response.json():
313+
if command_info["details"]["name"] == command.value:
314+
return command_info["commandId"]
145315

146-
raise ValueError("Remote start transaction command not found")
147-
148-
def start_remote_transaction(
149-
self, station_name: str, rfid: str, connector_id: int = 1
150-
):
151-
"""
152-
In short: this method will turn on your EV charger.
153-
154-
This method starts a remote transaction on the specified station using the provided RFID and connector ID.
155-
It first retrieves the command ID for the remote start transaction, then obtains a ticket ID,
156-
and finally establishes a websocket connection to send the remote start command.
157-
"""
158-
if not self.bearer_token:
159-
raise ValueError("Must login first before starting transaction")
160-
161-
logging.info("Starting remote transaction...")
162-
command_id = self.get_remote_start_command_id(station_name)
163-
ticket_id = self.get_ticket()
164-
165-
ws = websocket.create_connection(InCharge.WEBSOCKET_URL)
166-
167-
ticket_auth_msg = {"type": "TICKET_AUTH", "id": ticket_id}
168-
ws.send(json.dumps(ticket_auth_msg))
169-
logging.info(f"Sent TICKET_AUTH: {ticket_auth_msg}")
170-
171-
response = ws.recv()
172-
logging.info(f"Received: {response}")
173-
174-
time.sleep(1)
175-
176-
custom_command_msg = {
177-
"type": "CUSTOM_COMMAND",
178-
"commandId": command_id,
179-
"stations": [station_name],
180-
"parameters": {"connectorId": connector_id, "idTag": rfid},
181-
}
182-
ws.send(json.dumps(custom_command_msg))
183-
logging.info(f"Sent CUSTOM_COMMAND: {custom_command_msg}")
184-
185-
while True:
186-
msg = ws.recv()
187-
logging.info(f"Received: {msg}")
188-
msg_json = json.loads(msg)
189-
if msg_json.get("type") == "RESPONSE":
190-
payload = json.loads(msg_json.get("payload", "{}"))
191-
if payload.get("status") == "Accepted":
192-
logging.info("Remote start accepted.")
193-
return True
194-
elif payload.get("status") == "Rejected":
195-
logging.error("Remote start rejected.")
196-
return False
316+
raise ValueError(f"Command {command.name} not found")

0 commit comments

Comments
 (0)