系列: ocpp
python
464 行
· 更新於 2026-02-03
messages.py
ocpp/homework/ocpp/messages.py
"""Module containing classes that model the several OCPP messages types. It
also contain some helper functions for packing and unpacking messages."""
from __future__ import annotations
import asyncio
import decimal
import json
import os
from dataclasses import asdict, is_dataclass
from typing import Callable, Dict, Union
from jsonschema import Draft4Validator
from jsonschema.exceptions import ValidationError as SchemaValidationError
from ocpp.exceptions import (
FormatViolationError,
NotImplementedError,
OCPPError,
PropertyConstraintViolationError,
ProtocolError,
TypeConstraintViolationError,
UnknownCallErrorCodeError,
ValidationError,
)
_validators: Dict[str, Draft4Validator] = {}
ASYNC_VALIDATION = True
class _DecimalEncoder(json.JSONEncoder):
"""Encode values of type `decimal.Decimal` using 1 decimal point.
A custom encoder is required because `json.dumps()` cannot encode a value
of type decimal.Decimal. This raises a TypeError:
>>> import decimal
>>> import json
>>> >>> json.dumps(decimal.Decimal(3))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/developer/.pyenv/versions/3.7.0/lib/python3.7/json/__init__.py", line 231, in dumps # noqa
return _default_encoder.encode(obj)
File "/home/developer/.pyenv/versions/3.7.0/lib/python3.7/json/encoder.py", line 199, in encode
chunks = self.iterencode(o, _one_shot=True)
File "/home/developer/.pyenv/versions/3.7.0/lib/python3.7/json/encoder.py", line 257, in iterencode
return _iterencode(o, 0)
File "/home/developer/.pyenv/versions/3.7.0/lib/python3.7/json/encoder.py", line 179, in default
raise TypeError(f'Object of type {o.__class__.__name__} '
TypeError: Object of type Decimal is not JSON serializable
This can be prevented by using a custom encoder.
"""
def default(self, obj):
if isinstance(obj, decimal.Decimal):
return float("%.1f" % obj)
try:
return json.JSONEncoder.default(self, obj)
except TypeError as e:
try:
return obj.to_json()
except AttributeError:
raise e
class MessageType:
"""Number identifying the different types of OCPP messages."""
#: Call identifies a request.
Call = 2
#: CallResult identifies a successful response.
CallResult = 3
#: CallError identifies an erroneous response.
CallError = 4
def unpack(msg):
"""
Unpacks a message into either a Call, CallError or CallResult.
"""
try:
msg = json.loads(msg)
except (json.JSONDecodeError, UnicodeDecodeError):
raise FormatViolationError(
details={"cause": "Message is not valid JSON", "ocpp_message": msg}
)
if not isinstance(msg, list):
raise ProtocolError(
details={
"cause": (
"OCPP message hasn't the correct format. It "
f"should be a list, but got '{type(msg)}' "
"instead"
)
}
)
for cls in [Call, CallResult, CallError]:
try:
if msg[0] == cls.message_type_id:
return cls(*msg[1:])
except IndexError:
raise ProtocolError(
details={"cause": "Message does not contain MessageTypeId"}
)
except TypeError:
raise ProtocolError(details={"cause": "Message is missing elements."})
raise PropertyConstraintViolationError(
details={"cause": f"MessageTypeId '{msg[0]}' isn't valid"}
)
def pack(msg):
"""
Returns the JSON representation of a Call, CallError or CallResult.
It just calls the 'to_json()' method of the message. But it is here mainly
to complement the 'unpack' function of this module.
"""
return msg.to_json()
def get_validator(
message_type_id: int, action: str, ocpp_version: str, parse_float: Callable = float
) -> Draft4Validator:
"""
Read schema from disk and return as `Draft4Validator`. Instances will be
cached for performance reasons.
The `parse_float` argument can be used to set the conversion method that
is used to parse floats. It must be a callable taking 1 argument. By
default it is `float()`, but certain schema's require `decimal.Decimal()`.
"""
if ocpp_version not in ["1.6", "2.0", "2.0.1", "2.1"]:
raise ValueError
schemas_dir = "v" + ocpp_version.replace(".", "")
schema_name = action
if message_type_id == MessageType.CallResult:
schema_name += "Response"
elif message_type_id == MessageType.Call:
if ocpp_version in ["2.0", "2.0.1", "2.1"]:
schema_name += "Request"
if ocpp_version == "2.0":
schema_name += "_v1p0"
cache_key = schema_name + "_" + ocpp_version
if cache_key in _validators:
return _validators[cache_key]
dir, _ = os.path.split(os.path.realpath(__file__))
relative_path = f"{schemas_dir}/schemas/{schema_name}.json"
path = os.path.join(dir, relative_path)
# The JSON schemas for OCPP 2.0 start with a byte order mark (BOM)
# character. If no encoding is given, reading the schema would fail with:
#
# Unexpected UTF-8 BOM (decode using utf-8-sig):
with open(path, "r", encoding="utf-8-sig") as f:
data = f.read()
validator = Draft4Validator(json.loads(data, parse_float=parse_float))
_validators[cache_key] = validator
return _validators[cache_key]
async def validate_payload(message: Union[Call, CallResult], ocpp_version: str) -> None:
"""Validate the payload of the message using JSON schemas."""
if ASYNC_VALIDATION:
await asyncio.get_event_loop().run_in_executor(
None, _validate_payload, message, ocpp_version
)
else:
_validate_payload(message, ocpp_version)
def _validate_payload(message: Union[Call, CallResult], ocpp_version: str) -> None:
if type(message) not in [Call, CallResult]:
raise ValidationError(
"Payload can't be validated because message "
f"type. It's '{type(message)}', but it should "
"be either 'Call' or 'CallResult'."
)
try:
# 3 OCPP 1.6 schedules have fields of type floats. The JSON schema
# defines a certain precision for these fields of 1 decimal. A value of
# 21.4 is valid, whereas a value if 4.11 is not.
#
# The problem is that Python's internal representation of 21.4 might
# have more than 1 decimal. It might be 21.399999999999995. This would
# make the validation fail, although the payload is correct. This is a
# known issue with jsonschemas, see:
# https://github.com/Julian/jsonschema/issues/247
#
# This issue can be fixed by using a different parser for floats than
# the default one that is used.
#
# Both the schema and the payload must be parsed using the different
# parser for floats.
if ocpp_version == "1.6" and (
(
isinstance(message, Call)
and message.action in ["SetChargingProfile", "RemoteStartTransaction"]
)
or (
isinstance(message, CallResult)
and message.action == "GetCompositeSchedule"
)
):
validator = get_validator(
message.message_type_id,
message.action,
ocpp_version,
parse_float=decimal.Decimal,
)
message.payload = json.loads(
json.dumps(message.payload), parse_float=decimal.Decimal
)
else:
validator = get_validator(
message.message_type_id, message.action, ocpp_version
)
except (OSError, json.JSONDecodeError):
raise NotImplementedError(
details={"cause": f"Failed to validate action: {message.action}"}
)
try:
validator.validate(message.payload)
except SchemaValidationError as e:
if e.validator == "type":
raise TypeConstraintViolationError(
details={"cause": e.message, "ocpp_message": message}
)
elif e.validator == "additionalProperties":
raise FormatViolationError(
details={"cause": e.message, "ocpp_message": message}
)
elif e.validator == "required":
raise ProtocolError(details={"cause": e.message})
elif e.validator == "maxLength":
raise TypeConstraintViolationError(
details={"cause": e.message, "ocpp_message": message}
) from e
else:
raise FormatViolationError(
details={
"cause": f"Payload '{message.payload}' for action "
f"'{message.action}' is not valid: {e}",
"ocpp_message": message,
}
)
class Call:
"""A Call is a type of message that initiate a request/response sequence.
Both central systems and charge points can send this message.
From the specification:
A Call always consists of 4 elements: The standard elements
MessageTypeId and UniqueId, a specific Action that is required on the
other side and a payload, the arguments to the Action. The syntax of a
call looks like this:
[<MessageTypeId>, "<UniqueId>", "<Action>", {<Payload>}]
...
For example, a BootNotification request could look like this:
[2,
"19223201",
"BootNotification",
{
"chargePointVendor": "VendorX",
"chargePointModel": "SingleSocketCharger"
}
]
"""
message_type_id = 2
def __init__(self, unique_id, action, payload):
self.unique_id = unique_id
self.action = action
self.payload = payload
if is_dataclass(payload):
self.payload = asdict(payload)
def to_json(self):
"""Return a valid JSON representation of the instance."""
return json.dumps(
[
self.message_type_id,
self.unique_id,
self.action,
self.payload,
],
# By default json.dumps() adds a white space after every separator.
# By setting the separator manually that can be avoided.
separators=(",", ":"),
cls=_DecimalEncoder,
)
def create_call_result(self, payload):
call_result = CallResult(self.unique_id, payload)
call_result.action = self.action
return call_result
def create_call_error(self, exception):
error_code = "InternalError"
error_description = "An unexpected error occurred."
error_details = {}
if isinstance(exception, OCPPError):
error_code = exception.code
error_description = exception.description
error_details = exception.details
return CallError(
self.unique_id,
error_code,
error_description,
error_details,
)
def __repr__(self):
return (
f"<Call - unique_id={self.unique_id}, action={self.action}, "
f"payload={self.payload}>"
)
class CallResult:
"""
A CallResult is a message indicating that a Call has been handled
successfully.
From the specification:
A CallResult always consists of 3 elements: The standard elements
MessageTypeId, UniqueId and a payload, containing the response to the
Action in the original Call. The syntax of a call looks like this:
[<MessageTypeId>, "<UniqueId>", {<Payload>}]
...
For example, a BootNotification response could look like this:
[3,
"19223201",
{
"status":"Accepted",
"currentTime":"2013-02-01T20:53:32.486Z",
"heartbeatInterval":300
}
]
"""
message_type_id = 3
def __init__(self, unique_id, payload, action=None):
self.unique_id = unique_id
self.payload = payload
# Strictly speaking no action is required in a CallResult. But in order
# to validate the message it is needed.
self.action = action
def to_json(self):
return json.dumps(
[
self.message_type_id,
self.unique_id,
self.payload,
],
# By default json.dumps() adds a white space after every separator.
# By setting the separator manually that can be avoided.
separators=(",", ":"),
cls=_DecimalEncoder,
)
def __repr__(self):
return (
f"<CallResult - unique_id={self.unique_id}, "
f"action={self.action}, "
f"payload={self.payload}>"
)
class CallError:
"""
A CallError is a response to a Call that indicates an error.
From the specification:
CallError always consists of 5 elements: The standard elements
MessageTypeId and UniqueId, an errorCode string, an errorDescription
string and an errorDetails object.
The syntax of a call looks like this:
[<MessageTypeId>, "<UniqueId>", "<errorCode>", "<errorDescription>", {<errorDetails>}] # noqa
"""
message_type_id = 4
def __init__(self, unique_id, error_code, error_description, error_details=None):
self.unique_id = unique_id
self.error_code = error_code
self.error_description = error_description
self.error_details = error_details
def to_json(self):
return json.dumps(
[
self.message_type_id,
self.unique_id,
self.error_code,
self.error_description,
self.error_details,
],
# By default json.dumps() adds a white space after every separator.
# By setting the separator manually that can be avoided.
separators=(",", ":"),
cls=_DecimalEncoder,
)
def to_exception(self):
"""Return the exception that corresponds to the CallError."""
for error in OCPPError.__subclasses__():
if error.code == self.error_code:
return error(
description=self.error_description, details=self.error_details
)
raise UnknownCallErrorCodeError(
f"Error code '{self.error_code}' is not defined by the"
" OCPP specification"
)
def __repr__(self):
return (
f"<CallError - unique_id={self.unique_id}, "
f"error_code={self.error_code}, "
f"error_description={self.error_description}, "
f"error_details={self.error_details}>"
)
相關文章
ocpp
python
更新於 2026-02-03
central_system.py
central_system.py — python source code from the ocpp learning materials (ocpp/homework/central_system.py).
閱讀文章 →
ocpp
python
更新於 2026-02-03
charge_point.py
charge_point.py — python source code from the ocpp learning materials (ocpp/homework/ocpp/charge_point.py).
閱讀文章 →
ocpp
python
更新於 2026-02-03
exceptions.py
exceptions.py — python source code from the ocpp learning materials (ocpp/homework/ocpp/exceptions.py).
閱讀文章 →
ocpp
python
更新於 2026-02-03
routing.py
routing.py — python source code from the ocpp learning materials (ocpp/homework/ocpp/routing.py).
閱讀文章 →
ocpp
python
更新於 2026-02-03
__init__.py
__init__.py — python source code from the ocpp learning materials (ocpp/homework/ocpp/v16/__init__.py).
閱讀文章 →
ocpp
python
更新於 2026-02-03
call.py
call.py — python source code from the ocpp learning materials (ocpp/homework/ocpp/v16/call.py).
閱讀文章 →