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:
initimmediately issues the boot notification CALL.- An
Acceptedreply moves the station toRegisteredand starts the heartbeat cycle: wheneverheartbeat_duepasses, a heartbeat CALL is emitted and the next one is scheduled one interval later. If the previous heartbeat is still unanswered when the next falls due (the CSMS granted an interval shorter than the call timeout, or has stopped answering), no new heartbeat is emitted — the cycle just reschedules — so heartbeats never pile up in the endpoint’s queue or block application calls behind them. PendingandRejectedreplies schedule a fresh boot attempt after the interval the CSMS asked for, falling back toboot_retry_mswhen that interval is zero or the reply is unreadable. A boot call that fails outright (CALLERROR, timeout, undecodable response) retries afterboot_retry_msas well.- The spec forbids a station from sending anything but responses before
it is accepted, so application-initiated CALLs and SENDs made before
Registeredare queued FIFO in the station and released, in order, on acceptance. Inbound CALLs and the application’s replies to them always pass straight through — the CSMS may talk to a pending station.
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
- Offline transaction-event queueing and persistence (retaining and replaying TransactionEvent/StopTransaction messages across disconnects) is not implemented; the pre-registration queue is in-memory and scoped to one connection.
- Heartbeats run on a fixed cadence from the last heartbeat emission; the spec allows skipping a heartbeat when another message was sent within the interval.
- Heartbeat responses (which carry the CSMS clock) are consumed silently; surfacing them for time synchronisation is left to a later iteration.
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
-
BootOutcome(status: protocol.BootStatus, interval_ms: Int)
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.
-
TickTime passed; sent by the adapter when the
next_deadlinetimer fires. -
SocketClosedThe transport closed. Fails every pending and queued call with
Disconnectedand 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
refsucceeded. -
CallFailed(ref: Int, failure: endpoint.CallFailure)The application’s CALL identified by
reffailed. -
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 onceretry_atpasses. -
RejectedRegistration(retry_at: Int)The CSMS answered
Rejected; a new boot notification goes out onceretry_atpasses. -
Registered(interval_ms: Int, heartbeat_due: Int)The CSMS accepted us. A heartbeat CALL is emitted whenever
heartbeat_duepasses, then rescheduledinterval_mslater.
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.
Nonemeans the response was not a readable boot reply, which is treated like a failed boot attempt (retry afterboot_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.