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