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