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:

KindSourceExample
Enums"enum": [...] in schema@enum RegistrationStatus with members RegistrationAccepted, etc.
Shared structsNamed object types used across multiple messagesChargingStation, IdTagInfo, Modem
Action structsOne per schema file (the message payload)BootNotificationRequest, HeartbeatResponse
using OCPPData
using OCPPData.V16
import JSON

Enums

Each OCPP enum becomes a Julia @enum with automatic JSON string conversion:

instances(RegistrationStatus) |> collect
3-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 → nothing

Field 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
end

The 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):

  1. Read all .json schema files from src/v16/schemas/
  2. Collect enums — walk every property in every schema, match "enum" arrays against V16_ENUM_REGISTRY, generate @enum types
  3. Collect nested types — find object-typed properties named in V16_NESTED_TYPE_NAMES, topologically sort by dependencies, generate structs
  4. Generate action structs — one struct per schema file
  5. Build registryV16_ACTIONS dict + 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
end

Deduplication 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.CustomData
OCPPData.V201.CustomData

Automatic Name Derivation

V201 needs no hand-curated registry because the schema names are descriptive:

Definition nameJulia typeRule
BootReasonEnumTypeBootReasonStrip EnumType suffix
ChargingStationTypeChargingStationStrip Type suffix
ModemTypeModemStrip 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 nothing or missing, it gets prefixed
# MessageTrigger enum — members are prefixed because "TransactionEvent"
# would collide with the TransactionEvent enum type
instances(OCPPData.V201.MessageTrigger) |> collect
11-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 = 10

V201 Generation Pipeline

The entry point is the @generate_ocpp_types_from_definitions macro (called from inside the V201 submodule):

  1. Read all .json schema files from src/v201/schemas/
  2. Merge definitions — collect every definitions section across all files into one dict, deduplicating by name
  3. Classify — separate into enums (have "enum" key) and objects (have "properties")
  4. Derive names — map definition names to Julia type names (strip EnumType/Type suffixes)
  5. Generate enums — determine prefixes, create @enum types with string conversion
  6. Generate shared structs — topologically sort by $ref dependencies, generate in dependency order (leaves first)
  7. Generate action structs — one struct per schema file
  8. Build registryV201_ACTIONS dict + 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)