Skip to content

Commit b3ac785

Browse files
Merge branch 'master' into URL-does-not-get-converted-from-snake_case-responder_url-to-camelCase-responderURL
2 parents dbe7a9a + 144b544 commit b3ac785

File tree

5 files changed

+255
-10
lines changed

5 files changed

+255
-10
lines changed

.readthedocs.yaml

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Read the Docs configuration file for Sphinx projects
2+
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
3+
4+
# Required
5+
version: 2
6+
7+
# Set the OS, Python version and other tools you might need
8+
build:
9+
os: ubuntu-lts-latest
10+
tools:
11+
python: "3.12"
12+
# You can also specify other tool versions:
13+
# nodejs: "20"
14+
# rust: "1.70"
15+
# golang: "1.20"
16+
17+
# Build documentation in the "docs/" directory with Sphinx
18+
sphinx:
19+
configuration: docs/source/conf.py
20+
# You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs
21+
# builder: "dirhtml"
22+
# Fail on all warnings to avoid broken references
23+
# fail_on_warning: true
24+
25+
# Optionally build your docs in additional formats such as PDF and ePub
26+
# formats:
27+
# - pdf
28+
# - epub
29+
30+
# Optional but recommended, declare the Python requirements required
31+
# to build your documentation
32+
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
33+
# python:
34+
# install:
35+
# - requirements: docs/requirements.txt

CHANGELOG.md

+8
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
# Change log
2+
3+
- [#547](https://github.com/mobilityhouse/ocpp/pull/547) Feat: Handle recursively serializing a dataclasses as a dictionary Thanks [@MacDue](https://github.com/MacDue)
4+
- [#601](https://github.com/mobilityhouse/ocpp/issues/601) Fix case conversion for soc in non "State of Charge" context
5+
- [#523](https://github.com/mobilityhouse/ocpp/issues/523) The serialisation of soc to SoC should not occur in camel case if it is existing at the beginning of a field
6+
- [#515](https://github.com/mobilityhouse/ocpp/issues/515) Update Readthedocs configuration
27
- [#602](https://github.com/mobilityhouse/ocpp/issues/602) Correct v2g serialisation/deserialisation
38
- [#557](https://github.com/mobilityhouse/ocpp/issues/557) OCPP 2.0.1 Wrong data type in CostUpdated total_cost
49
- [#564](https://github.com/mobilityhouse/ocpp/issues/564) Add support For Python 3.11 and 3.12
@@ -13,6 +18,9 @@
1318
- [#510](https://github.com/mobilityhouse/ocpp/issues/510) v2.0.1 UnitOfMeasureType - Enums missing and update docstring to allow use for variableCharacteristics
1419
- [#508](https://github.com/mobilityhouse/ocpp/issues/508) Exception - OccurrenceConstraintViolationError doc string correction
1520

21+
## DEPRECATED ##
22+
- [#579](https://github.com/mobilityhouse/ocpp/issues/579) v2.0.1 Action enums corrected - IMPORTANT SEE UPGRADE PATH [#579](https://github.com/mobilityhouse/ocpp/issues/579)
23+
1624
## BREAKING ##
1725
- [#574](https://github.com/mobilityhouse/ocpp/issues/574) Remove v1.6 deprecated enum members - IMPORTANT see upgrade path [#574](https://github.com/mobilityhouse/ocpp/issues/574)
1826
- [#498](https://github.com/mobilityhouse/ocpp/issues/498) Remove support for OCPP 2.0 - IMPORTANT SEE UPGRADE PATH [#498](https://github.com/mobilityhouse/ocpp/issues/498)

ocpp/charge_point.py

+72-4
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
import re
55
import time
66
import uuid
7-
from dataclasses import asdict
8-
from typing import Dict, List, Union
7+
from dataclasses import Field, asdict, is_dataclass
8+
from typing import Any, Dict, List, Union, get_args, get_origin
99

1010
from ocpp.exceptions import NotImplementedError, NotSupportedError, OCPPError
1111
from ocpp.messages import Call, MessageType, unpack, validate_payload
@@ -59,6 +59,9 @@ def snake_to_camel_case(data):
5959
key = key.replace("_v2x", "V2X")
6060
key = key.replace("ocpp_csms", "ocppCSMS")
6161
key = key.replace("_url", "URL")
62+
key = key.replace("soc", "SoC").replace("_SoCket", "Socket")
63+
key = key.replace("_v2x", "V2X")
64+
key = key.replace("soc_limit_reached", "SOCLimitReached")
6265
key = key.replace("_v2x", "V2X").replace("_v2g", "V2G")
6366
components = key.split("_")
6467
key = components[0] + "".join(x[:1].upper() + x[1:] for x in components[1:])
@@ -76,6 +79,71 @@ def snake_to_camel_case(data):
7679
return data
7780

7881

82+
def _is_dataclass_instance(input: Any) -> bool:
83+
"""Verify if given `input` is a dataclass."""
84+
return is_dataclass(input) and not isinstance(input, type)
85+
86+
87+
def _is_optional_field(field: Field) -> bool:
88+
"""Verify if given `field` allows `None` as value.
89+
90+
The fields `schema` and `host` on the following class would return `False`.
91+
While the fields `post` and `query` return `True`.
92+
93+
@dataclass
94+
class URL:
95+
schema: str,
96+
host: str,
97+
post: Optional[str],
98+
query: Union[None, str]
99+
100+
"""
101+
return get_origin(field.type) is Union and type(None) in get_args(field.type)
102+
103+
104+
def serialize_as_dict(dataclass):
105+
"""Serialize the given `dataclass` as a `dict` recursively.
106+
107+
@dataclass
108+
class StatusInfoType:
109+
reason_code: str
110+
additional_info: Optional[str] = None
111+
112+
with_additional_info = StatusInfoType(
113+
reason="Unknown",
114+
additional_info="More details"
115+
)
116+
117+
assert serialize_as_dict(with_additional_info) == {
118+
'reason': 'Unknown',
119+
'additional_info': 'More details',
120+
}
121+
122+
without_additional_info = StatusInfoType(reason="Unknown")
123+
124+
assert serialize_as_dict(with_additional_info) == {
125+
'reason': 'Unknown',
126+
'additional_info': None,
127+
}
128+
129+
"""
130+
serialized = asdict(dataclass)
131+
132+
for field in dataclass.__dataclass_fields__.values():
133+
134+
value = getattr(dataclass, field.name)
135+
if _is_dataclass_instance(value):
136+
serialized[field.name] = serialize_as_dict(value)
137+
continue
138+
139+
if isinstance(value, list):
140+
for item in value:
141+
if _is_dataclass_instance(item):
142+
serialized[field.name] = [serialize_as_dict(item)]
143+
144+
return serialized
145+
146+
79147
def remove_nones(data: Union[List, Dict]) -> Union[List, Dict]:
80148
if isinstance(data, dict):
81149
return {k: remove_nones(v) for k, v in data.items() if v is not None}
@@ -249,7 +317,7 @@ async def _handle_call(self, msg):
249317

250318
return
251319

252-
temp_response_payload = asdict(response)
320+
temp_response_payload = serialize_as_dict(response)
253321

254322
# Remove nones ensures that we strip out optional arguments
255323
# which were not set and have a default value of None
@@ -311,7 +379,7 @@ async def call(self, payload, suppress=True, unique_id=None):
311379
CallError.
312380
313381
"""
314-
camel_case_payload = snake_to_camel_case(asdict(payload))
382+
camel_case_payload = snake_to_camel_case(serialize_as_dict(payload))
315383

316384
unique_id = (
317385
unique_id if unique_id is not None else str(self._unique_id_generator())

ocpp/v201/enums.py

+75-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from warnings import warn
2+
13
try:
24
# breaking change introduced in python 3.11
35
from enum import StrEnum
@@ -11,6 +13,14 @@ class StrEnum(str, Enum): # pragma: no cover
1113
class Action(StrEnum):
1214
"""An Action is a required part of a Call message."""
1315

16+
def __init__(self, *args, **kwargs):
17+
warn(
18+
message="Action enum contains deprecated members and will be removed in "
19+
"the next major release, please use snake case members.",
20+
category=DeprecationWarning,
21+
)
22+
23+
# --------- Soon to be deprecated ---------------------
1424
Authorize = "Authorize"
1525
BootNotification = "BootNotification"
1626
CancelReservation = "CancelReservation"
@@ -75,9 +85,72 @@ class Action(StrEnum):
7585
UnlockConnector = "UnlockConnector"
7686
UnpublishFirmware = "UnpublishFirmware"
7787
UpdateFirmware = "UpdateFirmware"
88+
# --------------------------------------------------------
7889

79-
80-
# Enums
90+
authorize = "Authorize"
91+
boot_notification = "BootNotification"
92+
cancel_reservation = "CancelReservation"
93+
certificate_signed = "CertificateSigned"
94+
change_availability = "ChangeAvailability"
95+
clear_cache = "ClearCache"
96+
clear_charging_profile = "ClearChargingProfile"
97+
clear_display_message = "ClearDisplayMessage"
98+
cleared_charging_limit = "ClearedChargingLimit"
99+
clear_variable_monitoring = "ClearVariableMonitoring"
100+
cost_update = "CostUpdate"
101+
customer_information = "CustomerInformation"
102+
data_transfer = "DataTransfer"
103+
delete_certificate = "DeleteCertificate"
104+
firmware_status_notification = "FirmwareStatusNotification"
105+
get_15118_ev_certificate = "Get15118EVCertificate"
106+
get_base_report = "GetBaseReport"
107+
get_certificate_status = "GetCertificateStatus"
108+
get_charging_profiles = "GetChargingProfiles"
109+
get_composite_schedule = "GetCompositeSchedule"
110+
get_display_messages = "GetDisplayMessages"
111+
get_installed_certificate_ids = "GetInstalledCertificateIds"
112+
get_local_list_version = "GetLocalListVersion"
113+
get_log = "GetLog"
114+
get_monitoring_report = "GetMonitoringReport"
115+
get_report = "GetReport"
116+
get_transaction_status = "GetTransactionStatus"
117+
get_variables = "GetVariables"
118+
heartbeat = "Heartbeat"
119+
install_certificate = "InstallCertificate"
120+
log_status_notification = "LogStatusNotification"
121+
meter_values = "MeterValues"
122+
notify_charging_limit = "NotifyChargingLimit"
123+
notify_customer_information = "NotifyCustomerInformation"
124+
notify_display_messages = "NotifyDisplayMessages"
125+
notify_ev_charging_needs = "NotifyEVChargingNeeds"
126+
notify_ev_charging_schedule = "NotifyEVChargingSchedule"
127+
notify_event = "NotifyEvent"
128+
notify_monitoring_report = "NotifyMonitoringReport"
129+
notify_report = "NotifyReport"
130+
publish_firmware = "PublishFirmware"
131+
publish_firmware_status_notification = "PublishFirmwareStatusNotification"
132+
report_charging_profiles = "ReportChargingProfiles"
133+
request_start_transaction = "RequestStartTransaction"
134+
request_stop_transaction = "RequestStopTransaction"
135+
reservation_status_update = "ReservationStatusUpdate"
136+
reserve_now = "ReserveNow"
137+
reset = "Reset"
138+
security_event_notification = "SecurityEventNotification"
139+
send_local_list = "SendLocalList"
140+
set_charging_profile = "SetChargingProfile"
141+
set_display_message = "SetDisplayMessage"
142+
set_monitoring_base = "SetMonitoringBase"
143+
set_monitoring_level = "SetMonitoringLevel"
144+
set_network_profile = "SetNetworkProfile"
145+
set_variable_monitoring = "SetVariableMonitoring"
146+
set_variables = "SetVariables"
147+
sign_certificate = "SignCertificate"
148+
status_notification = "StatusNotification"
149+
transaction_event = "TransactionEvent"
150+
trigger_message = "TriggerMessage"
151+
unlock_connector = "UnlockConnector"
152+
unpublish_firmware = "UnpublishFirmware"
153+
update_firmware = "UpdateFirmware"
81154

82155

83156
class APNAuthenticationType(StrEnum):

tests/test_charge_point.py

+65-4
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
import pytest
44

5-
from ocpp.charge_point import camel_to_snake_case, remove_nones, snake_to_camel_case
5+
from ocpp.charge_point import (
6+
camel_to_snake_case,
7+
remove_nones,
8+
serialize_as_dict,
9+
snake_to_camel_case,
10+
)
611
from ocpp.messages import Call
712
from ocpp.routing import after, create_route_map, on
813
from ocpp.v16 import ChargePoint as cp_16
@@ -11,8 +16,15 @@
1116
from ocpp.v16.datatypes import MeterValue, SampledValue
1217
from ocpp.v16.enums import Action, RegistrationStatus
1318
from ocpp.v201 import ChargePoint as cp_201
14-
from ocpp.v201.call import SetNetworkProfile
15-
from ocpp.v201.datatypes import NetworkConnectionProfileType
19+
from ocpp.v201.call import GetVariables as v201GetVariables
20+
from ocpp.v201.call import SetNetworkProfile as v201SetNetworkProfile
21+
from ocpp.v201.datatypes import (
22+
ComponentType,
23+
EVSEType,
24+
GetVariableDataType,
25+
NetworkConnectionProfileType,
26+
VariableType,
27+
)
1628
from ocpp.v201.enums import OCPPInterfaceType, OCPPTransportType, OCPPVersionType
1729

1830

@@ -58,6 +70,7 @@ def heartbeat(self, **kwargs):
5870
({"InvalidURL": "foo.com"}, {"invalid_url": "foo.com"}),
5971
({"evMinV2XEnergyRequest": 200}, {"ev_min_v2x_energy_request": 200}),
6072
({"v2xChargingCtrlr": 200}, {"v2x_charging_ctrlr": 200}),
73+
({"webSocketPingInterval": 200}, {"web_socket_ping_interval": 200}),
6174
({"signV2GCertificate": 200}, {"sign_v2g_certificate": 200}),
6275
(
6376
{"v2gCertificateInstallationEnabled": 200},
@@ -75,12 +88,14 @@ def test_camel_to_snake_case(test_input, expected):
7588
[
7689
({"transaction_id": "74563478"}, {"transactionId": "74563478"}),
7790
({"full_soc": 100}, {"fullSoC": 100}),
91+
({"soc_limit_reached": 200}, {"SoCLimitReached": 200}),
7892
({"ev_min_v2x_energy_request": 200}, {"evMinV2XEnergyRequest": 200}),
7993
({"v2x_charging_ctrlr": 200}, {"v2xChargingCtrlr": 200}),
8094
({"responder_url": "foo.com"}, {"responderURL": "foo.com"}),
8195
({"url": "foo.com"}, {"url": "foo.com"}),
8296
({"ocpp_csms_url": "foo.com"}, {"ocppCSMSURL": "foo.com"}),
8397
({"invalid_url": "foo.com"}, {"invalidURL": "foo.com"}),
98+
({"web_socket_ping_interval": 200}, {"webSocketPingInterval": 200}),
8499
({"sign_v2g_certificate": 200}, {"signV2GCertificate": 200}),
85100
(
86101
{"v2g_certificate_installation_enabled": 200},
@@ -130,7 +145,9 @@ def test_nested_remove_nones():
130145
apn=None,
131146
)
132147

133-
payload = SetNetworkProfile(configuration_slot=1, connection_data=connection_data)
148+
payload = v201SetNetworkProfile(
149+
configuration_slot=1, connection_data=connection_data
150+
)
134151
payload = asdict(payload)
135152

136153
assert expected_payload == remove_nones(payload)
@@ -251,6 +268,50 @@ def test_remove_nones_with_list_of_strings():
251268
}
252269

253270

271+
def test_serialize_as_dict():
272+
"""
273+
Test recursively serializing a dataclasses as a dictionary.
274+
"""
275+
# Setup
276+
expected = camel_to_snake_case(
277+
{
278+
"getVariableData": [
279+
{
280+
"component": {
281+
"name": "Component",
282+
"instance": None,
283+
"evse": {
284+
"id": 1,
285+
"connectorId": None,
286+
},
287+
},
288+
"variable": {
289+
"name": "Variable",
290+
"instance": None,
291+
},
292+
"attributeType": None,
293+
}
294+
],
295+
"customData": None,
296+
}
297+
)
298+
299+
payload = v201GetVariables(
300+
get_variable_data=[
301+
GetVariableDataType(
302+
component=ComponentType(
303+
name="Component",
304+
evse=EVSEType(id=1),
305+
),
306+
variable=VariableType(name="Variable"),
307+
)
308+
]
309+
)
310+
311+
# Execute / Assert
312+
assert serialize_as_dict(payload) == expected
313+
314+
254315
@pytest.mark.asyncio
255316
async def test_call_unique_id_added_to_handler_args_correctly(connection):
256317
"""

0 commit comments

Comments
 (0)