Commit | Line | Data |
---|---|---|
b794f42e | 1 | import argparse |
fa16d389 S |
2 | import asyncio |
3 | import logging | |
fa16d389 | 4 | from datetime import datetime, timezone |
cbfbfbc1 | 5 | from functools import partial |
8fe113d7 | 6 | from random import randint |
cbfbfbc1 | 7 | from typing import Optional |
fa16d389 | 8 | |
a89844d4 | 9 | import ocpp.v201 |
1a0d2c47 | 10 | import websockets |
fa16d389 | 11 | from ocpp.routing import on |
d4aa9700 JB |
12 | from ocpp.v201.enums import ( |
13 | Action, | |
14 | AuthorizationStatusType, | |
15 | ClearCacheStatusType, | |
e3861e41 | 16 | GenericDeviceModelStatusType, |
d4aa9700 | 17 | RegistrationStatusType, |
f937c172 | 18 | ReportBaseType, |
b794f42e | 19 | TransactionEventType, |
d4aa9700 | 20 | ) |
a5c2d21f | 21 | from websockets import ConnectionClosed |
fa16d389 | 22 | |
cbfbfbc1 JB |
23 | from timer import Timer |
24 | ||
fa16d389 S |
25 | # Setting up the logging configuration to display debug level messages. |
26 | logging.basicConfig(level=logging.DEBUG) | |
27 | ||
a5c2d21f JB |
28 | ChargePoints = set() |
29 | ||
1a0d2c47 | 30 | |
fa16d389 | 31 | # Define a ChargePoint class inheriting from the OCPP 2.0.1 ChargePoint class. |
a89844d4 | 32 | class ChargePoint(ocpp.v201.ChargePoint): |
cbfbfbc1 JB |
33 | _command_timer: Optional[Timer] |
34 | ||
35 | def __init__(self, connection): | |
36 | super().__init__(connection.path.strip("/"), connection) | |
37 | self._command_timer = None | |
4de4645d | 38 | |
aea49501 | 39 | # Message handlers to receive OCPP messages. |
339f65ad | 40 | @on(Action.BootNotification) |
d6488e8d | 41 | async def on_boot_notification(self, charging_station, reason, **kwargs): |
7628b7e6 | 42 | logging.info("Received %s", Action.BootNotification) |
d6488e8d | 43 | # Create and return a BootNotification response with the current time, |
5dd22b9f | 44 | # an interval of 60 seconds, and an accepted status. |
339f65ad | 45 | return ocpp.v201.call_result.BootNotification( |
d6488e8d | 46 | current_time=datetime.now(timezone.utc).isoformat(), |
115f3b17 | 47 | interval=60, |
d4aa9700 | 48 | status=RegistrationStatusType.accepted, |
d6488e8d | 49 | ) |
1a0d2c47 | 50 | |
115f3b17 | 51 | @on(Action.Heartbeat) |
5dd22b9f | 52 | async def on_heartbeat(self, **kwargs): |
7628b7e6 | 53 | logging.info("Received %s", Action.Heartbeat) |
d4aa9700 JB |
54 | return ocpp.v201.call_result.Heartbeat( |
55 | current_time=datetime.now(timezone.utc).isoformat() | |
56 | ) | |
115f3b17 | 57 | |
65c0600c | 58 | @on(Action.StatusNotification) |
d4aa9700 JB |
59 | async def on_status_notification( |
60 | self, timestamp, evse_id: int, connector_id: int, connector_status, **kwargs | |
61 | ): | |
7628b7e6 | 62 | logging.info("Received %s", Action.StatusNotification) |
65c0600c JB |
63 | return ocpp.v201.call_result.StatusNotification() |
64 | ||
5dd22b9f JB |
65 | @on(Action.Authorize) |
66 | async def on_authorize(self, id_token, **kwargs): | |
7628b7e6 | 67 | logging.info("Received %s", Action.Authorize) |
5dd22b9f | 68 | return ocpp.v201.call_result.Authorize( |
d4aa9700 | 69 | id_token_info={"status": AuthorizationStatusType.accepted} |
8430af0a | 70 | ) |
5dd22b9f | 71 | |
22c4f1fc | 72 | @on(Action.TransactionEvent) |
d4aa9700 JB |
73 | async def on_transaction_event( |
74 | self, | |
75 | event_type: TransactionEventType, | |
76 | timestamp, | |
77 | trigger_reason, | |
78 | seq_no: int, | |
79 | transaction_info, | |
80 | **kwargs, | |
81 | ): | |
22c4f1fc JB |
82 | match event_type: |
83 | case TransactionEventType.started: | |
7628b7e6 | 84 | logging.info("Received %s Started", Action.TransactionEvent) |
22c4f1fc | 85 | return ocpp.v201.call_result.TransactionEvent( |
d4aa9700 | 86 | id_token_info={"status": AuthorizationStatusType.accepted} |
8430af0a | 87 | ) |
22c4f1fc | 88 | case TransactionEventType.updated: |
7628b7e6 | 89 | logging.info("Received %s Updated", Action.TransactionEvent) |
d4aa9700 | 90 | return ocpp.v201.call_result.TransactionEvent(total_cost=10) |
22c4f1fc | 91 | case TransactionEventType.ended: |
7628b7e6 | 92 | logging.info("Received %s Ended", Action.TransactionEvent) |
22c4f1fc JB |
93 | return ocpp.v201.call_result.TransactionEvent() |
94 | ||
5dd22b9f | 95 | @on(Action.MeterValues) |
c7f80bf9 | 96 | async def on_meter_values(self, evse_id: int, meter_value, **kwargs): |
7628b7e6 | 97 | logging.info("Received %s", Action.MeterValues) |
5dd22b9f JB |
98 | return ocpp.v201.call_result.MeterValues() |
99 | ||
d6488e8d | 100 | # Request handlers to emit OCPP messages. |
4de4645d | 101 | async def _send_clear_cache(self): |
a89844d4 JB |
102 | request = ocpp.v201.call.ClearCache() |
103 | response = await self.call(request) | |
104 | ||
105 | if response.status == ClearCacheStatusType.accepted: | |
7628b7e6 | 106 | logging.info("%s successful", Action.ClearCache) |
a89844d4 | 107 | else: |
7628b7e6 | 108 | logging.info("%s failed", Action.ClearCache) |
1a0d2c47 | 109 | |
4de4645d | 110 | async def _send_get_base_report(self): |
b794f42e | 111 | request = ocpp.v201.call.GetBaseReport( |
8fe113d7 JB |
112 | request_id=randint(1, 100), # noqa: S311 |
113 | report_base=ReportBaseType.full_inventory, | |
299eb3fa S |
114 | ) |
115 | response = await self.call(request) | |
b794f42e | 116 | |
e3861e41 | 117 | if response.status == GenericDeviceModelStatusType.accepted: |
299eb3fa S |
118 | logging.info("%s successful", Action.GetBaseReport) |
119 | else: | |
891ae31d | 120 | logging.info("%s failed", Action.GetBaseReport) |
b2254601 | 121 | |
4de4645d | 122 | async def _send_command(self, command_name: Action): |
118332f4 | 123 | logging.debug("Sending OCPP command %s", command_name) |
299eb3fa S |
124 | match command_name: |
125 | case Action.ClearCache: | |
4de4645d | 126 | await self._send_clear_cache() |
299eb3fa | 127 | case Action.GetBaseReport: |
4de4645d | 128 | await self._send_get_base_report() |
e3861e41 JB |
129 | case _: |
130 | logging.info(f"Not supported command {command_name}") | |
299eb3fa | 131 | |
cbfbfbc1 JB |
132 | async def send_command( |
133 | self, command_name: Action, delay: Optional[float], period: Optional[float] | |
134 | ): | |
4de4645d | 135 | try: |
cbfbfbc1 JB |
136 | if delay and not self._command_timer: |
137 | self._command_timer = Timer( | |
138 | delay, | |
139 | False, | |
140 | self._send_command, | |
141 | [command_name], | |
142 | ) | |
143 | if period and not self._command_timer: | |
144 | self._command_timer = Timer( | |
4de4645d | 145 | period, |
cbfbfbc1 | 146 | True, |
4de4645d JB |
147 | self._send_command, |
148 | [command_name], | |
149 | ) | |
4de4645d JB |
150 | except ConnectionClosed: |
151 | self.handle_connection_closed() | |
152 | ||
153 | def handle_connection_closed(self): | |
154 | logging.info("ChargePoint %s closed connection", self.id) | |
155 | if self._command_timer: | |
156 | self._command_timer.cancel() | |
157 | ChargePoints.remove(self) | |
e3861e41 | 158 | logging.debug("Connected ChargePoint(s): %d", len(ChargePoints)) |
f937c172 | 159 | |
fa16d389 S |
160 | |
161 | # Function to handle new WebSocket connections. | |
cbfbfbc1 JB |
162 | async def on_connect( |
163 | websocket, | |
164 | command_name: Optional[Action], | |
165 | delay: Optional[float], | |
166 | period: Optional[float], | |
167 | ): | |
d4aa9700 | 168 | """For every new charge point that connects, create a ChargePoint instance and start |
ad8df5d3 JB |
169 | listening for messages. |
170 | """ | |
d6488e8d | 171 | try: |
d4aa9700 | 172 | requested_protocols = websocket.request_headers["Sec-WebSocket-Protocol"] |
d6488e8d JB |
173 | except KeyError: |
174 | logging.info("Client hasn't requested any Subprotocol. Closing Connection") | |
175 | return await websocket.close() | |
1a0d2c47 | 176 | |
d6488e8d JB |
177 | if websocket.subprotocol: |
178 | logging.info("Protocols Matched: %s", websocket.subprotocol) | |
179 | else: | |
d4aa9700 JB |
180 | logging.warning( |
181 | "Protocols Mismatched | Expected Subprotocols: %s," | |
182 | " but client supports %s | Closing connection", | |
183 | websocket.available_subprotocols, | |
184 | requested_protocols, | |
185 | ) | |
d6488e8d | 186 | return await websocket.close() |
1a0d2c47 | 187 | |
cbfbfbc1 JB |
188 | cp = ChargePoint(websocket) |
189 | if command_name: | |
190 | await cp.send_command(command_name, delay, period) | |
1a0d2c47 | 191 | |
a5c2d21f | 192 | ChargePoints.add(cp) |
cbfbfbc1 | 193 | |
7628b7e6 JB |
194 | try: |
195 | await cp.start() | |
196 | except ConnectionClosed: | |
4de4645d | 197 | cp.handle_connection_closed() |
1a0d2c47 | 198 | |
b738a0fc | 199 | |
93d95199 JB |
200 | def check_positive_number(value: Optional[float]): |
201 | try: | |
202 | value = float(value) | |
203 | except ValueError: | |
204 | raise argparse.ArgumentTypeError("must be a number") from None | |
205 | if value <= 0: | |
206 | raise argparse.ArgumentTypeError("must be a positive number") | |
207 | return value | |
208 | ||
209 | ||
fa16d389 S |
210 | # Main function to start the WebSocket server. |
211 | async def main(): | |
cbfbfbc1 | 212 | parser = argparse.ArgumentParser(description="OCPP2 Server") |
ba56e7c9 JB |
213 | parser.add_argument("-c", "--command", type=Action, help="command name") |
214 | group = parser.add_mutually_exclusive_group() | |
93d95199 JB |
215 | group.add_argument( |
216 | "-d", | |
217 | "--delay", | |
218 | type=check_positive_number, | |
219 | help="delay in seconds", | |
220 | ) | |
221 | group.add_argument( | |
222 | "-p", | |
223 | "--period", | |
224 | type=check_positive_number, | |
225 | help="period in seconds", | |
226 | ) | |
ba56e7c9 | 227 | group.required = parser.parse_known_args()[0].command is not None |
cbfbfbc1 JB |
228 | |
229 | args = parser.parse_args() | |
b2254601 | 230 | |
d6488e8d JB |
231 | # Create the WebSocket server and specify the handler for new connections. |
232 | server = await websockets.serve( | |
cbfbfbc1 JB |
233 | partial( |
234 | on_connect, command_name=args.command, delay=args.delay, period=args.period | |
235 | ), | |
d4aa9700 | 236 | "127.0.0.1", # Listen on loopback. |
d6488e8d | 237 | 9000, # Port number. |
d4aa9700 | 238 | subprotocols=["ocpp2.0", "ocpp2.0.1"], # Specify OCPP 2.0.1 subprotocols. |
d6488e8d JB |
239 | ) |
240 | logging.info("WebSocket Server Started") | |
f937c172 | 241 | |
d6488e8d JB |
242 | # Wait for the server to close (runs indefinitely). |
243 | await server.wait_closed() | |
1a0d2c47 | 244 | |
fa16d389 S |
245 | |
246 | # Entry point of the script. | |
d4aa9700 | 247 | if __name__ == "__main__": |
d6488e8d JB |
248 | # Run the main function to start the server. |
249 | asyncio.run(main()) |