Skip to content

Commit

Permalink
Allow to provide JSON schemas manually
Browse files Browse the repository at this point in the history
The OCA doesn't allow to share the JSON schemas for OCPP 2.1 outside
it's members. Thus this library can't include these schemas.

This commit introduces the `SchemaValidator`: a structure that loads
schemas located at inprovide folder and validates payload against those schemas.
The file names of the schema must follow the format '<action>Request' or
'<action>Response'. E.g.: "HeartbeatRequest" or
"BootNotificationResponse".

The file names for the schemas of OCPP 1.6 and OCPP 2.0 have been
adjusted to follow this pattern.

Users relying on `ocpp.v16`, `ocpp.v20` or `ocpp.v201` shouldn't be affected
by introduction of `SchemaValidator`. These modules create a default instance of `Validator`
to include the right set of schemas.

Users of `ocpp.v21` can create a custom validator and pass it to the
construct of `ocpp.v21.ChargePoint`. See also the two examples in
`examples/v21/`.

Fixes: #453
  • Loading branch information
OrangeTux committed Jul 21, 2023
1 parent 498c054 commit 57b16d0
Show file tree
Hide file tree
Showing 194 changed files with 3,719 additions and 280 deletions.
9 changes: 9 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ help:
@echo " If no version is provided, poetry outputs the current project version"
@echo " test run all the tests and linting"
@echo " update updates the dependencies in poetry.lock"
@echo " v21-central-system-example Run the example implementing an OCPP 2.1 central system.
@echo " v21-charge-point-example Run the example implementing an OCPP 2.1 charger.
@echo " update updates the dependencies in poetry.lock"
@echo ""
@echo "Check the Makefile to know exactly what each target is doing."

Expand Down Expand Up @@ -55,3 +58,9 @@ release: .install-poetry

deploy: update tests
poetry publish --build

v21-central-system-example:
PYTHONPATH=ocpp:$$PYTHONPATH poetry run python examples/v21/central_system.py

v21-charge-point-example:
PYTHONPATH=ocpp:$$PYTHONPATH poetry run python examples/v21/charge_point.py
58 changes: 58 additions & 0 deletions examples/v21/central_system.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import asyncio
import logging
import pathlib
from datetime import datetime, timezone

try:
import websockets
except ModuleNotFoundError:
print("This example relies on the 'websockets' package.")
print("Please install it by running: ")
print()
print(" $ pip install websockets")
import sys

sys.exit(1)

from ocpp.messages import SchemaValidator
from ocpp.routing import on
from ocpp.v21 import ChargePoint as cp
from ocpp.v21 import call_result
from ocpp.v21.enums import RegistrationStatus

logging.basicConfig(level=logging.INFO)

# The ocpp package doesn't come with the JSON schemas for OCPP 2.1.
# See https://github.com/mobilityhouse/ocpp/issues/458 for more details.
schemas_dir = str(pathlib.Path(__file__).parent.joinpath("schemas").resolve())
validator = SchemaValidator(schemas_dir)


class ChargePoint(cp):
@on("BootNotification")
def on_boot_notification(self, reason: str, charging_station: str, **kwargs):
return call_result.BootNotification(
current_time=datetime.now(timezone.utc).isoformat(),
interval=10,
status=RegistrationStatus.accepted,
)


async def on_connect(websocket, path):
charge_point_id = path.strip("/")
cp = ChargePoint(charge_point_id, websocket, validator)

await cp.start()


async def main():
server = await websockets.serve(
on_connect, "0.0.0.0", 9000, subprotocols=["ocpp2.1"]
)

logging.info("Server Started listening to new connections...")
await server.wait_closed()


if __name__ == "__main__":
asyncio.run(main())
54 changes: 54 additions & 0 deletions examples/v21/charge_point.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import asyncio
import logging
import pathlib

try:
import websockets
except ModuleNotFoundError:
print("This example relies on the 'websockets' package.")
print("Please install it by running: ")
print()
print(" $ pip install websockets")
import sys

sys.exit(1)

from ocpp.messages import SchemaValidator
from ocpp.v21 import ChargePoint as cp
from ocpp.v21 import call, call_result
from ocpp.v21.datatypes import ChargingStation
from ocpp.v21.enums import BootReason, RegistrationStatus

logging.basicConfig(level=logging.INFO)

schemas_dir = pathlib.Path(__file__).parent.joinpath("schemas").resolve()
validator = SchemaValidator(str(schemas_dir))


class ChargePoint(cp):
async def send_boot_notification(self):
request = call.BootNotification(
reason=BootReason.power_up,
charging_station=ChargingStation(
model="Virtual Charge Point",
vendor_name="y",
),
)

response: call_result.BootNotification = await self.call(request)

if response.status == RegistrationStatus.accepted:
print("Connected to central system.")


async def main():
async with websockets.connect(
"ws://localhost:9000/CP_1", subprotocols=["ocpp2.1"]
) as ws:
cp = ChargePoint("CP_1", ws, validator)

await asyncio.gather(cp.start(), cp.send_boot_notification())


if __name__ == "__main__":
asyncio.run(main())
105 changes: 105 additions & 0 deletions examples/v21/schemas/BootNotificationRequest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
{
"$schema": "http://json-schema.org/draft-06/schema#",
"$id": "urn:OCPP:Cp:2:2023:5:BootNotificationRequest",
"comment": "OCPP 2.1 Draft 1, Copyright Open Charge Alliance",
"definitions": {
"CustomDataType": {
"description": "This class does not get 'AdditionalProperties = false' in the schema generation, so it can be extended with arbitrary JSON properties to allow adding custom data.",
"javaType": "CustomData",
"type": "object",
"properties": {
"vendorId": {
"type": "string",
"maxLength": 255
}
},
"required": [
"vendorId"
]
},
"BootReasonEnumType": {
"javaType": "BootReasonEnum",
"type": "string",
"additionalProperties": false,
"enum": [
"ApplicationReset",
"FirmwareUpdate",
"LocalReset",
"PowerUp",
"RemoteReset",
"ScheduledReset",
"Triggered",
"Unknown",
"Watchdog"
]
},
"ChargingStationType": {
"javaType": "ChargingStation",
"type": "object",
"additionalProperties": false,
"properties": {
"customData": {
"$ref": "#/definitions/CustomDataType"
},
"serialNumber": {
"type": "string",
"maxLength": 25
},
"model": {
"type": "string",
"maxLength": 20
},
"modem": {
"$ref": "#/definitions/ModemType"
},
"vendorName": {
"type": "string",
"maxLength": 50
},
"firmwareVersion": {
"type": "string",
"maxLength": 50
}
},
"required": [
"model",
"vendorName"
]
},
"ModemType": {
"javaType": "Modem",
"type": "object",
"additionalProperties": false,
"properties": {
"customData": {
"$ref": "#/definitions/CustomDataType"
},
"iccid": {
"type": "string",
"maxLength": 20
},
"imsi": {
"type": "string",
"maxLength": 20
}
}
}
},
"type": "object",
"additionalProperties": false,
"properties": {
"customData": {
"$ref": "#/definitions/CustomDataType"
},
"chargingStation": {
"$ref": "#/definitions/ChargingStationType"
},
"reason": {
"$ref": "#/definitions/BootReasonEnumType"
}
},
"required": [
"reason",
"chargingStation"
]
}
79 changes: 79 additions & 0 deletions examples/v21/schemas/BootNotificationResponse.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
{
"$schema": "http://json-schema.org/draft-06/schema#",
"$id": "urn:OCPP:Cp:2:2023:5:BootNotificationResponse",
"comment": "OCPP 2.1 Draft 1, Copyright Open Charge Alliance",
"definitions": {
"CustomDataType": {
"description": "This class does not get 'AdditionalProperties = false' in the schema generation, so it can be extended with arbitrary JSON properties to allow adding custom data.",
"javaType": "CustomData",
"type": "object",
"properties": {
"vendorId": {
"type": "string",
"maxLength": 255
}
},
"required": [
"vendorId"
]
},
"RegistrationStatusEnumType": {
"javaType": "RegistrationStatusEnum",
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Pending",
"Rejected"
]
},
"StatusInfoType": {
"javaType": "StatusInfo",
"type": "object",
"additionalProperties": false,
"properties": {
"customData": {
"$ref": "#/definitions/CustomDataType"
},
"reasonCode": {
"type": "string",
"maxLength": 20
},
"additionalInfo": {
"type": "string",
"maxLength": 512
}
},
"required": [
"reasonCode"
]
}
},
"type": "object",
"additionalProperties": false,
"properties": {
"customData": {
"$ref": "#/definitions/CustomDataType"
},
"currentTime": {
"type": "string",
"format": "date-time"
},
"interval": {
"type": "integer",
"minimum": -2147483648.0,
"maximum": 2147483647.0
},
"status": {
"$ref": "#/definitions/RegistrationStatusEnumType"
},
"statusInfo": {
"$ref": "#/definitions/StatusInfoType"
}
},
"required": [
"currentTime",
"interval",
"status"
]
}
24 changes: 17 additions & 7 deletions ocpp/charge_point.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from typing import Dict, List, Union

from ocpp.exceptions import NotSupportedError, OCPPError
from ocpp.messages import Call, MessageType, unpack, validate_payload
from ocpp.messages import Call, MessageType, SchemaValidator, unpack, validate_payload
from ocpp.routing import create_route_map

LOGGER = logging.getLogger("ocpp")
Expand Down Expand Up @@ -85,7 +85,7 @@ class ChargePoint:
initiated and received by the Central System
"""

def __init__(self, id, connection, response_timeout=30):
def __init__(self, id, connection, validator: SchemaValidator, response_timeout=30):
"""
Args:
Expand All @@ -98,6 +98,8 @@ def __init__(self, id, connection, response_timeout=30):
"""
self.id = id

self._validator = validator

# The maximum time in seconds it may take for a CP to respond to a
# CALL. An asyncio.TimeoutError will be raised if this limit has been
# exceeded.
Expand Down Expand Up @@ -178,7 +180,7 @@ async def _handle_call(self, msg):
)

if not handlers.get("_skip_schema_validation", False):
validate_payload(msg, self._ocpp_version)
validate_payload(msg, self._validator)
# OCPP uses camelCase for the keys in the payload. It's more pythonic
# to use snake_case for keyword arguments. Therefore the keys must be
# 'translated'. Some examples:
Expand Down Expand Up @@ -221,7 +223,7 @@ async def _handle_call(self, msg):
response = msg.create_call_result(camel_case_payload)

if not handlers.get("_skip_schema_validation", False):
validate_payload(response, self._ocpp_version)
validate_payload(msg, self._validator)

await self._send(response.to_json())

Expand Down Expand Up @@ -265,13 +267,21 @@ async def call(self, payload, suppress=True, unique_id=None):
unique_id if unique_id is not None else str(self._unique_id_generator())
)

action = payload.__class__.__name__

# The call and call_result classes of OCPP 1.6, 2.0 and 2.0.1 are suffixed with 'Payload'.
# E.g. call_result.BootNotificationPayload. The suffixed doesn't make much sense and is removed
# as of OCPP 2.1.
if payload.__class__.__name__.endswith("Payload"):
action = payload.__class__.__name__[:-7]

call = Call(
unique_id=unique_id,
action=payload.__class__.__name__[:-7],
action=action,
payload=remove_nones(camel_case_payload),
)

validate_payload(call, self._ocpp_version)
validate_payload(call, self._validator)

# Use a lock to prevent make sure that only 1 message can be send at a
# a time.
Expand All @@ -294,7 +304,7 @@ async def call(self, payload, suppress=True, unique_id=None):
raise response.to_exception()
else:
response.action = call.action
validate_payload(response, self._ocpp_version)
validate_payload(response, self._validator)

snake_case_payload = camel_to_snake_case(response.payload)
# Create the correct Payload instance based on the received payload. If
Expand Down
Loading

0 comments on commit 57b16d0

Please sign in to comment.