Schema-Driven Type Generation
The Big Picture
OCPPData.jl does not contain hand-written Julia structs for OCPP messages. Instead, every struct and enum is generated automatically from the official OCPP JSON schema files when the package is first loaded.
src/v16/schemas/BootNotification.json ──┐
src/v16/schemas/BootNotificationResponse.json ──┤
src/v16/schemas/Heartbeat.json ──┤ schema_reader.jl
...56 schema files total ──┼─────────────────→ V16 module
│ macro expansion • 56 structs
│ • 32 enums
│ • V16_ACTIONS registry
│
src/v201/schemas/BootNotificationRequest.json ──┤
src/v201/schemas/BootNotificationResponse.json ──┤ schema_reader.jl
...128 schema files total ──┼─────────────────→ V201 module
│ macro expansion • 128+ structs
│ • 88 enums
│ • V201_ACTIONS registry
└This happens at macro-expansion time via two macros in schema_reader.jl: @generate_ocpp_types (V16) and @generate_ocpp_types_from_definitions (V201). Each macro reads the JSON schema files, builds Julia AST (Expr objects) for all enums, structs, and registries, and returns that AST via esc(Expr(:block, ...)) — standard macro expansion. After the first precompilation, everything is cached and subsequent using OCPPData is fast.
What Gets Generated
Three kinds of Julia types are created from each set of schema files:
| Kind | Source | Example |
|---|---|---|
| Enums | "enum": [...] in schema | @enum RegistrationStatus with members RegistrationAccepted, etc. |
| Shared structs | Named object types used across multiple messages | ChargingStation, IdTagInfo, Modem |
| Action structs | One per schema file (the message payload) | BootNotificationRequest, HeartbeatResponse |
using OCPPData
using OCPPData.V16
import JSONEnums
Each OCPP enum becomes a Julia @enum with automatic JSON string conversion:
instances(RegistrationStatus) |> collect3-element Vector{OCPPData.V16.RegistrationStatus}:
RegistrationAccepted::RegistrationStatus = 0
RegistrationPending::RegistrationStatus = 1
RegistrationRejected::RegistrationStatus = 2# string() returns the OCPP wire value, not the Julia symbol name
string(RegistrationAccepted)"Accepted"# JSON serialization uses the wire value
JSON.json(RegistrationAccepted)"\"Accepted\""Structs
Each message type becomes a Base.@kwdef struct. Required fields have no default; optional fields default to nothing:
fieldnames(BootNotificationRequest)(:charge_point_model, :charge_point_vendor, :charge_box_serial_number, :charge_point_serial_number, :firmware_version, :iccid, :imsi, :meter_serial_number, :meter_type)fieldtypes(BootNotificationRequest)(String, String, Union{Nothing, String}, Union{Nothing, String}, Union{Nothing, String}, Union{Nothing, String}, Union{Nothing, String}, Union{Nothing, String}, Union{Nothing, String})# Required fields must be provided, optional fields default to nothing
req = BootNotificationRequest(
charge_point_vendor = "MyVendor",
charge_point_model = "MyModel",
)
req.firmware_version # optional → nothingField names are automatically converted between camelCase (JSON wire format) and snake_case (Julia convention):
JSON.json(req)"{\"chargePointModel\":\"MyModel\",\"chargePointVendor\":\"MyVendor\",\"chargeBoxSerialNumber\":null,\"chargePointSerialNumber\":null,\"firmwareVersion\":null,\"iccid\":null,\"imsi\":null,\"meterSerialNumber\":null,\"meterType\":null}"Empty structs (like HeartbeatRequest with no fields) serialize as {}:
JSON.json(HeartbeatRequest())"{}"Action Registry
A dictionary mapping action names to their request/response type pairs:
V16.V16_ACTIONS["BootNotification"](request = OCPPData.V16.BootNotificationRequest, response = OCPPData.V16.BootNotificationResponse)V16.request_type("Heartbeat"), V16.response_type("Heartbeat")(OCPPData.V16.HeartbeatRequest, OCPPData.V16.HeartbeatResponse)How V16 Schemas Work
V16 schemas are flat — each .json file defines a single request or response with all properties inline. Here is a simplified view of BootNotification.json:
{
"title": "BootNotificationRequest",
"type": "object",
"properties": {
"chargePointVendor": { "type": "string", "maxLength": 20 },
"chargePointModel": { "type": "string", "maxLength": 20 },
"firmwareVersion": { "type": "string", "maxLength": 50 }
},
"required": ["chargePointVendor", "chargePointModel"]
}This becomes:
# Generated by @generate_ocpp_types in schema_reader.jl
@kwdef struct BootNotificationRequest
charge_point_vendor::String # required (from "required" array)
charge_point_model::String # required
firmware_version::Union{String, Nothing} = nothing # optional
endThe V16 Registry Problem
V16 schemas have a limitation: enum values appear inline as "enum": ["Accepted", "Pending", "Rejected"] without any type name. The same list ["Accepted", "Rejected"] might appear in several schemas meaning different things.
OCPPData.jl solves this with a hand-curated registry in src/v16/registries.jl:
const V16_ENUM_REGISTRY = Dict{Vector{String}, Tuple{Symbol, String}}(
["Accepted", "Pending", "Rejected"] => (:RegistrationStatus, "Registration"),
["Accepted", "Rejected"] => (:GenericStatus, "Generic"),
# ... 32 enum types total
)This tells the generator: "when you see the sorted values ["Accepted", "Pending", "Rejected"], create an enum called RegistrationStatus with prefix Registration" — yielding members RegistrationAccepted, RegistrationPending, RegistrationRejected.
Similarly, nested object types (like idTagInfo which appears in multiple V16 messages) are mapped via V16_NESTED_TYPE_NAMES:
const V16_NESTED_TYPE_NAMES = Dict{String, Symbol}(
"idTagInfo" => :IdTagInfo,
# ...
)V16 Generation Pipeline
The entry point is the @generate_ocpp_types macro (called from inside the V16 submodule):
- Read all
.jsonschema files fromsrc/v16/schemas/ - Collect enums — walk every property in every schema, match
"enum"arrays againstV16_ENUM_REGISTRY, generate@enumtypes - Collect nested types — find object-typed properties named in
V16_NESTED_TYPE_NAMES, topologically sort by dependencies, generate structs - Generate action structs — one struct per schema file
- Build registry —
V16_ACTIONSdict +request_type()/response_type()helpers
How V201 Schemas Work
V201 schemas are self-describing — each file contains a definitions section with explicitly named types, cross-referenced via $ref. Here is a simplified view of BootNotificationRequest.json:
{
"definitions": {
"BootReasonEnumType": {
"type": "string",
"enum": ["ApplicationReset", "FirmwareUpdate", "PowerUp", "..."]
},
"ChargingStationType": {
"type": "object",
"properties": {
"model": { "type": "string" },
"vendorName": { "type": "string" },
"modem": { "$ref": "#/definitions/ModemType" }
},
"required": ["model", "vendorName"]
},
"ModemType": {
"type": "object",
"properties": {
"iccid": { "type": "string" },
"imsi": { "type": "string" }
}
}
},
"properties": {
"reason": { "$ref": "#/definitions/BootReasonEnumType" },
"chargingStation": { "$ref": "#/definitions/ChargingStationType" }
},
"required": ["reason", "chargingStation"]
}This single file produces four Julia types:
# 1. Enum (from BootReasonEnumType definition)
@enum BootReason BootReasonApplicationReset BootReasonFirmwareUpdate BootReasonPowerUp ...
# 2. Shared struct (from ModemType definition — dependency of ChargingStationType)
@kwdef struct Modem
iccid::Union{String, Nothing} = nothing
imsi::Union{String, Nothing} = nothing
end
# 3. Shared struct (from ChargingStationType definition)
@kwdef struct ChargingStation
model::String
vendor_name::String
modem::Union{Modem, Nothing} = nothing
end
# 4. Action struct (the top-level message)
@kwdef struct BootNotificationRequest
reason::BootReason
charging_station::ChargingStation
endDeduplication Across Schemas
The same definitions (like ChargingStationType or CustomDataType) appear in many schema files. The generator merges all definitions into one dict first, so each shared type is created only once:
# CustomData appears in nearly every V201 schema, but is generated once
OCPPData.V201.CustomDataOCPPData.V201.CustomDataAutomatic Name Derivation
V201 needs no hand-curated registry because the schema names are descriptive:
| Definition name | Julia type | Rule |
|---|---|---|
BootReasonEnumType | BootReason | Strip EnumType suffix |
ChargingStationType | ChargingStation | Strip Type suffix |
ModemType | Modem | Strip Type suffix |
Enum Prefix Rules
Enum members are prefixed when needed to avoid collisions:
- Cross-enum collisions: If
"Accepted"appears in multiple enums, both get prefixed (e.g.,RegistrationAccepted,GenericAccepted) - Type name collisions: If a member name like
"TransactionEvent"would clash with a generated type name, the enum gets prefixed (e.g.,MessageTriggerTransactionEvent) - Base name collisions: If a member would shadow Julia builtins like
nothingormissing, it gets prefixed
# MessageTrigger enum — members are prefixed because "TransactionEvent"
# would collide with the TransactionEvent enum type
instances(OCPPData.V201.MessageTrigger) |> collect11-element Vector{OCPPData.V201.MessageTrigger}:
MessageTriggerBootNotification::MessageTrigger = 0
MessageTriggerLogStatusNotification::MessageTrigger = 1
MessageTriggerFirmwareStatusNotification::MessageTrigger = 2
MessageTriggerHeartbeat::MessageTrigger = 3
MessageTriggerMeterValues::MessageTrigger = 4
MessageTriggerSignChargingStationCertificate::MessageTrigger = 5
MessageTriggerSignV2GCertificate::MessageTrigger = 6
MessageTriggerStatusNotification::MessageTrigger = 7
MessageTriggerTransactionEvent::MessageTrigger = 8
MessageTriggerSignCombinedCertificate::MessageTrigger = 9
MessageTriggerPublishFirmwareStatusNotification::MessageTrigger = 10V201 Generation Pipeline
The entry point is the @generate_ocpp_types_from_definitions macro (called from inside the V201 submodule):
- Read all
.jsonschema files fromsrc/v201/schemas/ - Merge definitions — collect every
definitionssection across all files into one dict, deduplicating by name - Classify — separate into enums (have
"enum"key) and objects (have"properties") - Derive names — map definition names to Julia type names (strip
EnumType/Typesuffixes) - Generate enums — determine prefixes, create
@enumtypes with string conversion - Generate shared structs — topologically sort by
$refdependencies, generate in dependency order (leaves first) - Generate action structs — one struct per schema file
- Build registry —
V201_ACTIONSdict +request_type()/response_type()helpers
How the Macros Work
All types are generated by macros in schema_reader.jl. The macros run at macro-expansion time (during precompilation): they read the JSON schema files, build Julia Expr trees for every enum, struct, and registry, then return esc(Expr(:block, exprs...)) — the standard way a Julia macro splices code into its call site.
Core.eval is used only to evaluate the macro's own arguments (e.g., the schema directory path and the enum registry constant) at expansion time, not to define the generated types.
For an enum, the macro builds and returns an expression equivalent to:
# Returned as AST by @generate_ocpp_types / @generate_ocpp_types_from_definitions
@enum BootReason BootReasonPowerUp BootReasonApplicationReset ...
export BootReason, BootReasonPowerUp, ...
# Forward lookup: enum member → OCPP wire string
const _BOOTREASON_TO_STR = Dict(BootReasonPowerUp => "PowerUp", ...)
# Reverse lookup: OCPP wire string → enum member
const _STR_TO_BOOTREASON = Dict("PowerUp" => BootReasonPowerUp, ...)
# JSON serialization
Base.string(x::BootReason) = _BOOTREASON_TO_STR[x]
StructUtils.lift(::Type{BootReason}, s::AbstractString) = _STR_TO_BOOTREASON[String(s)]For a struct:
# Returned as AST by the macro
Base.@kwdef struct BootNotificationRequest
reason::BootReason
charging_station::ChargingStation
custom_data::Union{CustomData, Nothing} = nothing
end
export BootNotificationRequest
# camelCase ↔ snake_case field name mapping
StructUtils.fieldtags(::StructUtils.StructStyle, ::Type{BootNotificationRequest}) =
(charging_station = (json = (name = "chargingStation",),),
custom_data = (json = (name = "customData",),))Inspecting What Was Generated
You can explore all generated types at runtime:
# All V16 action names
sort(collect(keys(V16.V16_ACTIONS)))28-element Vector{String}:
"Authorize"
"BootNotification"
"CancelReservation"
"ChangeAvailability"
"ChangeConfiguration"
"ClearCache"
"ClearChargingProfile"
"DataTransfer"
"DiagnosticsStatusNotification"
"FirmwareStatusNotification"
⋮
"Reset"
"SendLocalList"
"SetChargingProfile"
"StartTransaction"
"StatusNotification"
"StopTransaction"
"TriggerMessage"
"UnlockConnector"
"UpdateFirmware"# Total types per version
v16_names = filter(n -> getfield(OCPPData.V16, n) isa DataType, names(OCPPData.V16))
v201_names = filter(n -> getfield(OCPPData.V201, n) isa DataType, names(OCPPData.V201))
length(v16_names), length(v201_names)(96, 272)