ocpp/protocol/station

A pure, sans-IO OCPP charging-station machine.

Wraps the generic RPC endpoint (ocpp/protocol/endpoint) and adds the station side of the provisioning lifecycle:

Version specifics (what a boot request looks like, how to read the boot outcome from a response, what a heartbeat is) enter only through accessor functions on StationConfig, so this one machine serves OCPP 1.6, 2.0.1 and 2.1 alike.

Time and deadlines

Time follows the endpoint’s convention: it enters exclusively as the now argument to update (absolute monotonic milliseconds). Registration deadlines (retry_at, heartbeat_due) live as fields of the Registration state that owns them, so a state change cancels them structurally, and they are checked before each message is handled — any input arriving after a deadline fires it, and a stale Tick is a harmless no-op. next_deadline merges the registration deadline with the wrapped endpoint’s call timeout, so the adapter still keeps just one timer.

Refs

The station issues CALLs of its own (boot, heartbeat), so it allocates internal refs for the endpoint and keeps a table mapping them back to their origin. Application refs survive the translation untouched: CallRequested(ref, ...) always resolves as CallSucceeded(ref, ...) or CallFailed(ref, ...) with the same ref, and boot/heartbeat traffic never surfaces as call outputs.

Future work

Types

The decision and retry/heartbeat interval read from a boot response. The status is the shared protocol.BootStatus vocabulary; the interval is station-specific, which is why this type lives here rather than beside it. interval_ms is the heartbeat interval when accepted and the re-boot wait otherwise; zero or negative means “no usable interval” and falls back to boot_retry_ms.

pub type BootOutcome {
  BootOutcome(status: protocol.BootStatus, interval_ms: Int)
}

Constructors

Inputs to the machine — the same vocabulary as the endpoint’s Msg, with ref values belonging to the application (the station translates them to and from its internal endpoint refs).

pub type Msg(req, res) {
  WireIn(text: String)
  CallRequested(ref: Int, request: req)
  SendRequested(request: req)
  ReplyProvided(
    id: String,
    reply: Result(res, #(rpc.ErrorCode, String)),
  )
  Tick
  SocketClosed
}

Constructors

  • WireIn(text: String)

    A text frame arrived from the peer.

  • CallRequested(ref: Int, request: req)

    The application wants to make a CALL. Queued in the station until the CSMS accepts the boot notification, then subject to the endpoint’s one-in-flight rule.

  • SendRequested(request: req)

    The application wants to emit a SEND (2.1). Queued until acceptance like a CALL (the station may not send before it is registered), but unconfirmed and exempt from the in-flight rule once released.

  • ReplyProvided(
      id: String,
      reply: Result(res, #(rpc.ErrorCode, String)),
    )

    The application answers the inbound CALL with this id. Passes through regardless of registration state — responses are always allowed.

  • Tick

    Time passed; sent by the adapter when the next_deadline timer fires.

  • SocketClosed

    The transport closed. Fails every pending and queued call with Disconnected and makes the machine terminal.

Outputs emitted by the machine: the endpoint’s outputs with refs translated back to the application’s, minus boot/heartbeat traffic, which the station consumes itself.

pub type Output(req, res) {
  WireOut(text: String)
  CallSucceeded(ref: Int, response: res)
  CallFailed(ref: Int, failure: endpoint.CallFailure)
  CallReceived(id: String, request: req)
  SendReceived(request: req)
  ReplyRejected(id: String, code: String, description: String)
  ViolationDetected(violation: endpoint.Violation)
}

Constructors

  • WireOut(text: String)

    Write this text frame to the socket.

  • CallSucceeded(ref: Int, response: res)

    The application’s CALL identified by ref succeeded.

  • CallFailed(ref: Int, failure: endpoint.CallFailure)

    The application’s CALL identified by ref failed.

  • CallReceived(id: String, request: req)

    The CSMS initiated a CALL. Answer it with ReplyProvided(id, ...).

  • SendReceived(request: req)

    The CSMS emitted a SEND. No reply is possible.

  • ReplyRejected(id: String, code: String, description: String)

    The CSMS rejected a CALLRESULT we previously sent (2.1).

  • ViolationDetected(violation: endpoint.Violation)

    A protocol irregularity, reported by the wrapped endpoint.

Where the station stands in the registration lifecycle. Deadlines are fields of the state that owns them (the single-timer pattern): leaving the state cancels the deadline structurally.

pub type Registration {
  Booting(boot_ref: Int)
  PendingRegistration(retry_at: Int)
  RejectedRegistration(retry_at: Int)
  Registered(interval_ms: Int, heartbeat_due: Int)
}

Constructors

  • Booting(boot_ref: Int)

    Our boot notification CALL is in flight; its timeout is the wrapped endpoint’s call deadline, so this state carries none of its own.

  • PendingRegistration(retry_at: Int)

    The CSMS answered Pending (or the boot attempt failed); a new boot notification goes out once retry_at passes.

  • RejectedRegistration(retry_at: Int)

    The CSMS answered Rejected; a new boot notification goes out once retry_at passes.

  • Registered(interval_ms: Int, heartbeat_due: Int)

    The CSMS accepted us. A heartbeat CALL is emitted whenever heartbeat_due passes, then rescheduled interval_ms later.

A charging station on one connection. Construct with init, advance with update.

pub opaque type Station(req, res)

Version-specific bindings for the station machine, on top of the wrapped endpoint’s codec Config.

pub type StationConfig(req, res) {
  StationConfig(
    endpoint: endpoint.Config(req, res),
    make_boot_request: fn() -> req,
    read_boot_outcome: fn(res) -> option.Option(BootOutcome),
    make_heartbeat_request: fn() -> req,
    boot_retry_ms: Int,
  )
}

Constructors

  • StationConfig(
      endpoint: endpoint.Config(req, res),
      make_boot_request: fn() -> req,
      read_boot_outcome: fn(res) -> option.Option(BootOutcome),
      make_heartbeat_request: fn() -> req,
      boot_retry_ms: Int,
    )

    Arguments

    endpoint

    Codecs and call timeout for the wrapped RPC endpoint.

    make_boot_request

    Build this version’s boot notification request (1.6/2.x BootNotification).

    read_boot_outcome

    Read the boot decision out of a response to our boot request. None means the response was not a readable boot reply, which is treated like a failed boot attempt (retry after boot_retry_ms).

    make_heartbeat_request

    Build this version’s heartbeat request.

    boot_retry_ms

    Fallback wait (ms) before re-booting when the CSMS supplies no usable interval, and the retry wait after a failed boot call. Also substitutes for a zero/absent heartbeat interval on acceptance, so a misbehaving CSMS cannot demand a heartbeat flood.

Values

pub fn init(
  config: StationConfig(req, res),
  now: Int,
) -> #(Station(req, res), effect.Effect(Output(req, res)))

Create a station for a fresh connection and immediately emit its boot notification CALL. now is the current time in absolute monotonic milliseconds (same convention as update).

pub fn next_deadline(
  station: Station(req, res),
) -> option.Option(Int)

The earliest instant at which this machine needs a Tick to make progress: the minimum of the registration deadline (boot retry or heartbeat) and the wrapped endpoint’s call timeout.

pub fn registration(station: Station(req, res)) -> Registration

Where the station currently stands in the registration lifecycle.

pub fn update(
  station: Station(req, res),
  msg: Msg(req, res),
  now: Int,
) -> #(Station(req, res), effect.Effect(Output(req, res)))

Advance the machine with one input at time now (absolute milliseconds, monotonic). Expired registration deadlines fire before the message is handled, and the wrapped endpoint applies its own call-timeout rule.

Search Document