Skip to content

Commit e9289ab

Browse files
committed
add: Receive server command handler.
1 parent bf98b71 commit e9289ab

14 files changed

Lines changed: 1148 additions & 718 deletions

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ import utime
2626
import _thread
2727

2828
from usr.tools import uwebsocket, logging
29-
from usr.ocpp.v16 import call
29+
from usr.ocpp.routing import on
3030
from usr.ocpp.v16 import ChargePoint as cp
31+
from usr.ocpp.v16.enums import RegistrationStatus, Action
3132

3233
logger = logging.getLogger(__name__)
3334

@@ -37,7 +38,7 @@ IMEI = modem.getDevImei()
3738
class ChargePoint(cp):
3839

3940
def send_boot_notification(self):
40-
request = call.BootNotificationPayload(
41+
request = self._call.BootNotificationPayload(
4142
charge_point_model="ICU Eve Mini",
4243
charge_point_vendor="Alfen BV",
4344
firmware_version="#1:3.4.0-2990#N:217H;1.0-223"
@@ -48,6 +49,14 @@ class ChargePoint(cp):
4849
if response.status == RegistrationStatus.accepted:
4950
logger.info("Connected to central system.")
5051

52+
@on(Action.CancelReservation)
53+
def on_cancel_reservation(self, reservation_id):
54+
logger.info("reservation_id %s" % (reservation_id))
55+
56+
return self._call_result.CancelReservationPayload(
57+
status=CancelReservationStatus.accepted
58+
)
59+
5160

5261
if __name__ == "__main__":
5362
# Init websocket client.
@@ -84,6 +93,7 @@ if __name__ == "__main__":
8493
|-- dataclasses.py
8594
|-- exceptions.py
8695
|-- messages.py
96+
|-- routing.py
8797
|-- tools
8898
|-- logging.py
8999
|-- uuid.py

code/ocpp/charge_point.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
@copyright :Copyright (c) 2024
2525
"""
2626

27+
import sys
2728
import ure
2829
import utime
2930
import queue
@@ -41,6 +42,7 @@
4142
OCPPError,
4243
TimeoutError
4344
)
45+
from usr.ocpp.routing import create_route_map
4446

4547
LOGGER = logging.getLogger(__name__)
4648

@@ -198,6 +200,11 @@ def __init__(self, id, connection, response_timeout=30):
198200
# A connection to the client. Currently this is an instance of gh
199201
self._connection = connection
200202

203+
# A dictionary that hooks for Actions. So if the CS receives a it will
204+
# look up the Action into this map and execute the corresponding hooks
205+
# if exists.
206+
self.route_map = create_route_map(self)
207+
201208
self._call_lock = _thread.allocate_lock()
202209

203210
# A queue used to pass CallResults and CallErrors from
@@ -234,9 +241,108 @@ def route_message(self, raw_msg):
234241
)
235242
return
236243

244+
if msg.message_type_id == MessageType.Call:
245+
try:
246+
self._handle_call(msg)
247+
except OCPPError as error:
248+
sys.print_exception(error)
249+
LOGGER.error("Error while handling request '%s'" % msg)
250+
response = msg.create_call_error(error).to_json()
251+
self._send(response)
252+
237253
if msg.message_type_id in [MessageType.CallResult, MessageType.CallError]:
238254
self._response_queue.put(msg)
239255

256+
def _handle_call(self, msg):
257+
"""
258+
Execute all hooks installed for based on the Action of the message.
259+
260+
First the '_on_action' hook is executed and its response is returned to
261+
the client. If there is no '_on_action' hook for Action in the message
262+
a CallError with a NotImplementedError is returned. If the Action is
263+
not supported by the OCPP version a NotSupportedError is returned.
264+
265+
Next the '_after_action' hook is executed.
266+
267+
"""
268+
try:
269+
handlers = self.route_map[msg.action]
270+
except KeyError:
271+
_raise_key_error(msg.action, self._ocpp_version)
272+
return
273+
274+
msg.payload = camel_to_snake_case(msg.payload)
275+
if not handlers.get("_skip_schema_validation", False):
276+
validate_payload(msg, self._ocpp_version)
277+
# OCPP uses camelCase for the keys in the payload. It's more pythonic
278+
# to use snake_case for keyword arguments. Therefore the keys must be
279+
# 'translated'. Some examples:
280+
#
281+
# * chargePointVendor becomes charge_point_vendor
282+
# * firmwareVersion becomes firmwareVersion
283+
# snake_case_payload = camel_to_snake_case(msg.payload)
284+
285+
try:
286+
handler = handlers["_on_action"]
287+
except KeyError:
288+
_raise_key_error(msg.action, self._ocpp_version)
289+
290+
try:
291+
# call_unique_id should be passed as kwarg only if is defined explicitly
292+
# in the handler signature
293+
if handler._call_unique_id_required:
294+
response = handler(**msg.payload, call_unique_id=msg.unique_id)
295+
else:
296+
response = handler(**msg.payload)
297+
# if inspect.isawaitable(response):
298+
# response = await response
299+
except Exception as e:
300+
sys.print_exception(e)
301+
LOGGER.error("Error while handling request '%s'" % msg)
302+
response = snake_to_camel_case(msg.create_call_error(e).to_json())
303+
self._send(response)
304+
305+
return
306+
307+
temp_response_payload = asdict(response)
308+
309+
# Remove nones ensures that we strip out optional arguments
310+
# which were not set and have a default value of None
311+
response_payload = remove_nones(temp_response_payload)
312+
313+
# The response payload must be 'translated' from snake_case to
314+
# camelCase. So:
315+
#
316+
# * charge_point_vendor becomes chargePointVendor
317+
# * firmware_version becomes firmwareVersion
318+
# camel_case_payload = snake_to_camel_case(response_payload)
319+
320+
response = msg.create_call_result(response_payload)
321+
322+
if not handlers.get("_skip_schema_validation", False):
323+
validate_payload(response, self._ocpp_version)
324+
325+
response.payload = snake_to_camel_case(response.payload)
326+
self._send(response.to_json())
327+
328+
try:
329+
handler = handlers["_after_action"]
330+
# call_unique_id should be passed as kwarg only if is defined explicitly
331+
# in the handler signature
332+
if handler._call_unique_id_required:
333+
response = handler(**msg.payload, call_unique_id=msg.unique_id)
334+
else:
335+
response = handler(**msg.payload)
336+
# Create task to avoid blocking when making a call inside the
337+
# after handler
338+
# if inspect.isawaitable(response):
339+
# asyncio.ensure_future(response)
340+
except KeyError:
341+
# '_on_after' hooks are not required. Therefore ignore exception
342+
# when no '_on_after' hook is installed.
343+
pass
344+
return response
345+
240346
def call(self, payload, suppress=True, unique_id=None):
241347
"""
242348
Send Call message to client and return payload of response.

code/ocpp/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
"""
16+
@file : exceptions.py
17+
@author : Jack Sun (jack.sun@quectel.com)
18+
@brief : <Description>
19+
@version : v1.0.0
20+
@date : 2024-03-27 09:02:33
21+
@copyright : Copyright (c) 2024
22+
"""
23+
1524
_OCPPErrorSubClasses_ = []
1625

1726

code/ocpp/messages.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,14 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
""" Module containing classes that model the several OCPP messages types. It
16-
also contain some helper functions for packing and unpacking messages. """
15+
"""
16+
@file : messages.py
17+
@author : Jack Sun (jack.sun@quectel.com)
18+
@brief : <Description>
19+
@version : v1.0.0
20+
@date : 2024-04-23 15:57:26
21+
@copyright : Copyright (c) 2024
22+
"""
1723

1824
import ujson
1925

@@ -31,6 +37,9 @@
3137
SchemaValidationError,
3238
)
3339

40+
""" Module containing classes that model the several OCPP messages types. It
41+
also contain some helper functions for packing and unpacking messages. """
42+
3443

3544
class MessageType:
3645
"""Number identifying the different types of OCPP messages."""

code/ocpp/routing.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Copyright (c) Quectel Wireless Solution, Co., Ltd.All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
@file : routing.py
17+
@author : Jack Sun (jack.sun@quectel.com)
18+
@brief : <Description>
19+
@version : v1.0.0
20+
@date : 2024-04-21 14:23:03
21+
@copyright : Copyright (c) 2024
22+
"""
23+
24+
routables = []
25+
26+
27+
class InnerBase:
28+
29+
def __init__(self, func):
30+
self.func = func
31+
self.func_name = repr(self.func).split(" ")[1]
32+
self.parent = None
33+
34+
def __call__(self, *args, **kwargs):
35+
return self.func(self.parent, *args, **kwargs)
36+
37+
38+
def on(action, skip_schema_validation=False, call_unique_id_required=False):
39+
"""
40+
Function decorator to mark function as handler for specific action. The
41+
wrapped function may be async or sync.
42+
43+
The handler function will receive keyword arguments derived from the
44+
payload of the specific action. It's recommended you use `**kwargs` in your
45+
definition to ignore any extra arguments that may be added in the future.
46+
47+
The handler function should return a relevant payload to be returned to the
48+
Charge Point.
49+
50+
It can be used like so:
51+
52+
```
53+
class MyChargePoint(cp):
54+
@on(Action.BootNotification):
55+
async def on_boot_notification(
56+
self,
57+
charge_point_model,
58+
charge_point_vendor,
59+
**kwargs,
60+
):
61+
print(f'{charge_point_model} from {charge_point_vendor} booted.')
62+
63+
now = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S') + "Z"
64+
return call_result.BootNotificationPayload(
65+
current_time=now,
66+
interval=30,
67+
status="Accepted",
68+
)
69+
```
70+
71+
The decorator takes an optional argument `skip_schema_validation` which
72+
defaults to False. Setting this argument to `True` will disable schema
73+
validation of the request and the response of the specific route.
74+
75+
"""
76+
77+
def decorator(func):
78+
inner = InnerBase(func)
79+
inner._on_action = action
80+
inner._skip_schema_validation = skip_schema_validation
81+
inner._call_unique_id_required = call_unique_id_required
82+
83+
if inner.func_name not in routables:
84+
routables.append(inner.func_name)
85+
return inner
86+
87+
return decorator
88+
89+
90+
def after(action, call_unique_id_required=False):
91+
"""Function decorator to mark function as hook to post-request hook.
92+
93+
This hook's arguments are the data that is in the payload for the specific
94+
action.
95+
96+
It can be used like so:
97+
98+
@after(Action.BootNotification):
99+
def after_boot_notification():
100+
pass
101+
102+
"""
103+
104+
def decorator(func):
105+
inner = InnerBase(func)
106+
inner._after_action = action
107+
inner._call_unique_id_required = call_unique_id_required
108+
if inner.func_name not in routables:
109+
routables.append(inner.func_name)
110+
return inner
111+
112+
return decorator
113+
114+
115+
def create_route_map(obj):
116+
"""
117+
Iterates of all attributes of the class looking for attributes which
118+
have been decorated by the @on() decorator It returns a dictionary where
119+
the action name are the keys and the decorated functions are the values.
120+
121+
To illustrate this with an example, consider the following function:
122+
123+
class ChargePoint:
124+
125+
@on(Action.BootNotification)
126+
def on_boot_notification(self, *args, **kwargs):
127+
pass
128+
129+
@after(Action.BootNotification)
130+
def after_boot_notification(self, *args, **kwargs):
131+
pass
132+
133+
134+
In this case this returns:
135+
136+
{
137+
Action.BootNotification: {
138+
'_on_action': <reference to 'on_boot_notification'>,
139+
'_after_action': <reference to 'after_boot_notification'>,
140+
'_skip_schema_validation': False,
141+
},
142+
}
143+
144+
"""
145+
routes = {}
146+
147+
for attr_name in routables:
148+
for option in ["_on_action", "_after_action"]:
149+
try:
150+
attr = getattr(obj, attr_name)
151+
action = getattr(attr, option)
152+
attr.parent = obj
153+
154+
if action not in routes:
155+
routes[action] = {}
156+
157+
# Routes decorated with the `@on()` decorator can be configured
158+
# to skip validation of the input and output. For more info see
159+
# the docstring of `on()`.
160+
if option == "_on_action":
161+
routes[action]["_skip_schema_validation"] = getattr(
162+
attr, "_skip_schema_validation", False
163+
)
164+
165+
routes[action][option] = attr
166+
167+
except AttributeError:
168+
continue
169+
170+
return routes

0 commit comments

Comments
 (0)