Battery Swapping - CSMS Developer Guide
Based on OCPP 2.1 Edition 2 Specification (Part 2), Section S (Battery Swapping). This guide covers all battery swap flows (S01–S04), including local and remote authorization, battery in/out events, charging transactions, error handling, and state management from the CSMS perspective using OCPP-J (JSON over WebSocket).
1. Overview
IntroductionBattery swapping is the process where an EV driver exchanges a near-empty battery for a charged one at a Battery Swap Station (BSS). The returned battery is placed in a dock and recharged. This section covers the CSMS implementation for managing the full battery swap lifecycle.
Key Concepts
Swap ≠ Charging Transaction
The swap action itself is recorded via BatterySwapRequest messages, not TransactionEvent messages.
Pricing can be based on SoC differences, monthly fees, etc.
Charging IS a Transaction
The actual charging of returned batteries in docks is tracked via standard TransactionEventRequest messages, like regular EV charging.
Device Model Mapping
A BSS is modeled as a Charging Station where each battery slot equals one logical EVSE. Each EVSE powers one battery slot.
Slot Availability Semantics
Available = slot is empty, Occupied = slot has a battery, Unavailable = slot cannot be used.
Important: The swap can consist of a single battery or a set of batteries
depending on the EV type. The default swap order is "In-Out" (old battery in
first, new battery out second). The reverse ("Out-In") is also supported and reported via BatterySwapCtrlr.SwapOrder.
Messages Involved (CSMS Perspective)
| Message | Direction | CSMS Role |
|---|---|---|
AuthorizeRequest | BSS → CSMS | Respond (grant/deny swap) |
RequestBatterySwapRequest | CSMS → BSS | Send (initiate remote swap) |
RequestBatterySwapResponse | BSS → CSMS | Receive (confirmation) |
BatterySwapRequest | BSS → CSMS | Respond (acknowledge in/out) |
NotifyEventRequest | BSS → CSMS | Respond (slot state changes) |
TransactionEventRequest | BSS → CSMS | Respond (charging transactions) |
2. Device Model & Slot Mapping
ArchitectureA Battery Swap Station (BSS) is represented in OCPP as a Charging Station where each battery slot maps to one logical EVSE. Each EVSE ID in battery swap messages refers to the slot number.
Slot Mapping
Battery Swap Station (ChargingStation)
├── EVSE 1 (Battery Slot 1) ── Connector (ConnectorType = slot-specific)
├── EVSE 2 (Battery Slot 2) ── Connector (ConnectorType = slot-specific)
├── EVSE 3 (Battery Slot 3) ── Connector (ConnectorType = slot-specific)
└── ...Note: Different battery slot types can be represented by specifying different ConnectorType values
per EVSE in the Device Model.
CSMS Slot Tracking
The CSMS should track the following data per slot:
| Field | Type | Description |
|---|---|---|
evseId | integer | The slot number (= EVSE ID) |
availabilityState | string | Available / Occupied / Unavailable |
batterySerialNumber | string | Serial number of battery currently in slot (if occupied) |
batterySoC | number | Current state of charge (0-100%) |
batterySoH | number | State of health (0-100%) |
chargingTransactionId | string | Active charging transaction ID (if battery is being charged) |
3. Configuration Variables
ReferenceThe CSMS should be aware of these BSS configuration variables, readable/writable via GetVariables / SetVariables.
| Variable | Description |
|---|---|
BatterySwapInTimeout | Timeout (seconds) for the EV driver to insert a battery after authorization. If exceeded, BSS aborts the swap. No BatterySwapRequest is sent. |
BatterySwapOutTimeout | Timeout (seconds) for old batteries to be removed after insertion of new ones. If exceeded, BSS sends BatterySwapRequest with eventType = BatteryOutTimeout. |
BatterySwapTargetSoc | Target SoC at which a battery in a slot becomes eligible for swapping. |
BatterySwapMaxSoc | Maximum SoC to which a battery is charged. Must be >= BatterySwapTargetSoc. |
BatterySwapIdtoken | Predefined idToken value used in TransactionEventRequest for battery charging transactions. If set, uses idToken.type = "Central". If not set, uses idToken.type = "NoAuthorization". |
BatterySwapCtrlr.SwapOrder | "In-Out" (default) or "Out-In". Indicates whether BSS does old-battery-in-first or new-battery-out-first. |
CustomImplementationEnabled | Boolean. If true, BSS supports the custom BatterySwapResponse extension for rejecting batteries. Vendor ID: org.openchargealliance.batteryswapresponse |
BatteryCartridgeSoC | Component/variable to report battery SoC per slot (readable via GetVariablesRequest). |
TxStartPoint | Must be "EVConnected" for battery swap stations. Transaction starts when battery is inserted. |
TxStopPoint | Must be "EVConnected" for battery swap stations. Transaction ends when battery is removed. |
4. S01 — Battery Swap Local Authorization
BSS-Initiated| Use Case ID | S01 |
| Direction | BSS → CSMS (BSS initiates) |
| Trigger | EV Driver presents RFID card at Battery Swap Station |
| OCPP Messages | AuthorizeRequest / AuthorizeResponse |
Flow Diagram
User BSS CSMS
| | |
|--present RFID-->| |
| |--AuthorizeRequest--->|
| | |-- validate idToken
| |<--AuthorizeResponse--|
|<--accept swap---| |CSMS Handler: AuthorizeRequest
When received, validate the idToken for swap
authorization.
Incoming Payload: AuthorizeRequest
| Field | Type | Required | Description |
|---|---|---|---|
idToken | IdTokenType | Yes | The token presented by the EV driver |
idToken.idToken | string (max 255) | Yes | The token value (case insensitive) |
idToken.type | string (max 20) | Yes | Token type (e.g., ISO14443, ISO15693, eMAID, Central) |
idToken.additionalInfo | AdditionalInfoType[] | No | Additional identification info |
certificate | string (max 10000) | No | X.509 PEM certificate chain (central contract validation) |
iso15118CertificateHashData | OCSPRequestDataType[] | No | OCSP data for certificate validation (max 4) |
Response Payload: AuthorizeResponse
| Field | Type | Required | Description |
|---|---|---|---|
idTokenInfo | IdTokenInfoType | Yes | Authorization result |
idTokenInfo.status | AuthorizationStatusEnumType | Yes | See status table below |
idTokenInfo.cacheExpiryDateTime | dateTime | No | Token cache expiry |
idTokenInfo.chargingPriority | integer | No | Priority (-9 to 9, default 0) |
idTokenInfo.groupIdToken | IdTokenType | No | Group token |
idTokenInfo.language1 | string (max 8) | No | Preferred UI language (RFC 5646) |
idTokenInfo.language2 | string (max 8) | No | Second preferred language |
idTokenInfo.evseId | integer[] | No | Restrict to specific EVSEs/slots |
idTokenInfo.personalMessage | MessageContentType | No | Message to display |
certificateStatus | AuthorizeCertificateStatusEnumType | No | Certificate validation result |
allowedEnergyTransfer | EnergyTransferModeEnumType[] | No | Allowed energy modes |
tariff | TariffType | No | Applicable tariff |
AuthorizationStatusEnumType Values
| Value | Description |
|---|---|
Accepted | Token is valid, swap authorized |
Blocked | Token is blocked |
Expired | Token has expired |
Invalid | Token is unknown/invalid |
NoCredit | No credit available |
NotAllowedTypeEVSE | Token not allowed for this EVSE type |
NotAtThisLocation | Token not valid at this location |
NotAtThisTime | Token not valid at this time |
Unknown | Token could not be validated (offline scenario) |
ConcurrentTx | Token already has an active transaction |
Implementation Logic
function handleAuthorizeRequest(stationId, request):
idToken = request.idToken
// 1. Look up the token in the authorization database
tokenRecord = database.findToken(idToken.idToken, idToken.type)
// 2. Validate the token
if tokenRecord is null:
return { idTokenInfo: { status: "Invalid" } }
if tokenRecord.isBlocked:
return { idTokenInfo: { status: "Blocked" } }
if tokenRecord.expiryDate < now():
return { idTokenInfo: { status: "Expired" } }
if not tokenRecord.hasValidSwapContract:
return { idTokenInfo: { status: "Invalid" } }
// 3. Check location-based restrictions
if not tokenRecord.isAllowedAtStation(stationId):
return { idTokenInfo: { status: "NotAtThisLocation" } }
// 4. Grant authorization
return {
idTokenInfo: {
status: "Accepted",
cacheExpiryDateTime: "2025-12-31T23:59:59Z",
language1: tokenRecord.preferredLanguage
}
}Requirements
The authorization for battery swapping follows the same process as use case C01 (EV Driver Authorization using RFID), but it is NOT used to start a charging transaction — it authorizes the swap service. Requirements about concurrent transactions from C01 do not apply since the swap authorization is not a transaction.
S01.FR.01 — Upon Accepted authorization
AND enough batteries available, the BSS opens empty slots for battery insertion.
S01.FR.02 — If the EV driver does not insert a battery before BatterySwapInTimeout,
the BSS ends authorization and aborts. No BatterySwapRequest is
sent to CSMS.
5. S02 — Battery Swap Remote Start
CSMS-Initiated| Use Case ID | S02 |
| Direction | CSMS → BSS (CSMS initiates) |
| Trigger | EV Driver requests battery swap remotely (e.g., via smartphone app) |
| OCPP Messages | RequestBatterySwapRequest / RequestBatterySwapResponse |
Flow Diagram
User CSMS BSS
| | |
|--request via app---------->| |
| |--RequestBatterySwap-->|
| | Request |
| |<--RequestBatterySwap--|
| | Response |
|<--------accept swap--------|---------------------->|CSMS Action: Send RequestBatterySwapRequest
This is a message the CSMS sends TO the BSS (CSMS-initiated).
Outgoing Payload: RequestBatterySwapRequest
| Field | Type | Required | Description |
|---|---|---|---|
idToken | IdTokenType | Yes | Authorization token for the EV driver |
idToken.idToken | string (max 255) | Yes | Token value |
idToken.type | string (max 20) | Yes | Token type |
idToken.additionalInfo | AdditionalInfoType[] | No | Additional info |
requestId | integer | Yes | Unique request ID to correlate with subsequent BatterySwapRequest messages |
{
"idToken": {
"idToken": "AABBCCDD11223344",
"type": "ISO14443"
},
"requestId": 42
}Handle Response: RequestBatterySwapResponse
| Field | Type | Required | Description |
|---|---|---|---|
status | GenericStatusEnumType | Yes | "Accepted" or "Rejected" |
statusInfo | StatusInfoType | No | Additional status details |
statusInfo.reasonCode | string (max 20) | Yes* | Predefined reason code (* if statusInfo present) |
statusInfo.additionalInfo | string (max 1024) | No | Human-readable details |
GenericStatusEnumType Values
| Value | Meaning |
|---|---|
Accepted | BSS accepts the swap request, enough batteries available |
Rejected | BSS rejects the swap request |
{
"status": "Accepted"
}{
"status": "Rejected",
"statusInfo": {
"reasonCode": "NoBatteryAvailable",
"additionalInfo": "All battery slots are empty or charging"
}
}Implementation Logic
function initiateBatterySwapRemote(stationId, userId):
// 1. Validate user authorization
user = database.findUser(userId)
if not user.hasValidSwapContract():
return error("User not authorized for battery swapping")
// 2. Generate unique requestId
requestId = generateUniqueRequestId()
// 3. Store the pending swap request for correlation
database.storePendingSwapRequest({
requestId: requestId,
stationId: stationId,
userId: userId,
idToken: user.idToken,
createdAt: now(),
status: "Pending"
})
// 4. Send the request to BSS
response = sendToBSS(stationId, "RequestBatterySwapRequest", {
idToken: {
idToken: user.idToken.value,
type: user.idToken.type
},
requestId: requestId
})
// 5. Handle response
if response.status == "Accepted":
database.updatePendingSwapRequest(requestId, { status: "Accepted" })
// Now wait for BatterySwapRequest messages (S03)
else:
database.updatePendingSwapRequest(requestId, {
status: "Rejected",
reason: response.statusInfo?.reasonCode
})
notifyUser(userId, "Swap rejected: " + response.statusInfo?.additionalInfo)Requirements
S02.FR.01 — The BSS accepts only if the idToken is authorized
by CSMS AND enough batteries are available.
S02.FR.02 — The BSS uses the same requestId from this
request in the subsequent BatterySwapRequest,
allowing CSMS to correlate the remote start with the actual swap event.
S02.FR.03 — CSMS SHALL NOT send RequestBatterySwapRequest for an unauthorized idToken.
S02.FR.04 — If the BSS receives a request with an authorized idToken but has no
batteries, it responds Rejected with reasonCode = "NoBatteryAvailable".
S02.FR.05 — If the EV driver does not insert a battery before BatterySwapInTimeout,
the BSS aborts. No BatterySwapRequest is sent.
6. S03 — Battery Swap In/Out
BSS-Initiated| Use Case ID | S03 |
| Direction | BSS → CSMS (BSS initiates) |
| Trigger | After authorization (S01 or S02), BSS records battery insertions and removals |
| OCPP Messages | BatterySwapRequest / BatterySwapResponse, NotifyEventRequest / NotifyEventResponse |
Flow Diagram (Default "In-Out" Order)
User BSS CSMS
| | |
| [User authorized for swapping - via S01 or S02] |
| | |
|--insert empty-->| |
| batteries | |
| |--BatterySwapRequest------------>| eventType=BatteryIn
| | (requestId, idToken, | batteryData[] with
| | batteryData[]) | evseId, serialNumber,
| |<--BatterySwapResponse-----------| soC, soH
| | |
| | [for each inserted battery] |
| |--NotifyEventRequest------------>| AvailabilityState
| | (Occupied) | = "Occupied"
| |<--NotifyEventResponse-----------|
| | |
| | [Start charging inserted battery]
| | |
|<--present new---| |
| batteries | |
| | |
|--take out new-->| |
| batteries | |
| | |
| | [If battery was still charging:] |
| |--TransactionEventRequest------->| eventType=Ended
| |<--TransactionEventResponse------|
| | |
| |--BatterySwapRequest------------>| eventType=BatteryOut
| | (same requestId, idToken, | batteryData[] with
| | batteryData[]) | evseId, serialNumber,
| |<--BatterySwapResponse-----------| soC, soH
| | |
| | [for each extracted battery] |
| |--NotifyEventRequest------------>| AvailabilityState
| | (Available) | = "Available"
| |<--NotifyEventResponse-----------|
| | |
|--leaves-------->| |CSMS Handler: BatterySwapRequest
This is the primary battery swap message. The BSS sends it to report battery insertion and removal events.
Incoming Payload: BatterySwapRequest
| Field | Type | Required | Description |
|---|---|---|---|
eventType | BatterySwapEventEnumType | Yes | Type of swap event |
requestId | integer | Yes | Correlates BatteryIn/Out events and optional RequestBatterySwapRequest |
idToken | IdTokenType | Yes | The authorized EV driver's token |
batteryData | BatteryDataType[] (min 1) | Yes | Array of battery data, one entry per battery |
BatterySwapEventEnumType Values
| Value | Description |
|---|---|
BatteryIn | Set of empty batteries has been inserted into the BSS |
BatteryOut | Set of charged batteries has been removed from the BSS |
BatteryOutTimeout | Old batteries were not removed within BatterySwapOutTimeout. CSMS receives an orphan BatteryIn without a matching BatteryOut. |
BatteryDataType Fields
| Field | Type | Required | Description |
|---|---|---|---|
evseId | integer (>= 0) | Yes | Slot number where battery was inserted/removed |
serialNumber | string (max 50) | Yes | Battery serial number |
soC | number (0.0-100.0) | Yes | State of Charge at time of event |
soH | number (0.0-100.0) | Yes | State of Health |
productionDate | dateTime | No | Battery production date |
vendorInfo | string (max 500) | No | Vendor-specific info (free format) |
Response Payload: BatterySwapResponse
Note: This is an empty acknowledgement — the request cannot be rejected via the standard response. See Error Handling for the customData extension rejection mechanism.
{
"eventType": "BatteryIn",
"requestId": 42,
"idToken": {
"idToken": "AABBCCDD11223344",
"type": "ISO14443"
},
"batteryData": [
{
"evseId": 3,
"serialNumber": "BAT-2025-001234",
"soC": 8.5,
"soH": 95.2,
"productionDate": "2024-06-15T00:00:00Z"
},
{
"evseId": 7,
"serialNumber": "BAT-2025-001235",
"soC": 12.1,
"soH": 97.8
}
]
}{
"eventType": "BatteryOut",
"requestId": 42,
"idToken": {
"idToken": "AABBCCDD11223344",
"type": "ISO14443"
},
"batteryData": [
{
"evseId": 1,
"serialNumber": "BAT-2025-000987",
"soC": 92.0,
"soH": 96.5
},
{
"evseId": 5,
"serialNumber": "BAT-2025-000654",
"soC": 88.3,
"soH": 94.1
}
]
}{}{
"customData": {
"vendorId": "org.openchargealliance.batteryswapresponse",
"status": "Rejected",
"statusInfo": {
"reasonCode": "BatteryUnknown",
"additionalInfo": "Not a battery of this CPO"
}
}
}CSMS Handler: NotifyEventRequest (Slot State Changes)
After each battery insertion/removal, the BSS sends NotifyEventRequest messages to report the AvailabilityState change for each affected slot.
Relevant EventDataType Fields
| Field | Type | Description |
|---|---|---|
eventId | integer (>= 0) | Unique event identifier |
timestamp | dateTime | When the event occurred |
trigger | EventTriggerEnumType | "Delta" for state change |
actualValue | string (max 2500) | The new value: "Occupied" or "Available" |
component.name | string | "Connector" |
component.evse.id | integer | Slot number |
variable.name | string | "AvailabilityState" |
{
"generatedAt": "2025-12-03T14:30:00Z",
"seqNo": 0,
"eventData": [
{
"eventId": 101,
"timestamp": "2025-12-03T14:30:00Z",
"trigger": "Delta",
"actualValue": "Occupied",
"eventNotificationType": "HardWiredNotification",
"component": {
"name": "Connector",
"evse": { "id": 3 }
},
"variable": {
"name": "AvailabilityState"
}
}
]
}Note: StatusNotificationRequest can also be used instead of NotifyEventRequest for
AvailabilityState, but StatusNotificationRequest has been deprecated. Prefer handling NotifyEventRequest.
Implementation Logic
function handleBatterySwapRequest(stationId, request):
eventType = request.eventType
requestId = request.requestId
idToken = request.idToken
batteries = request.batteryData
switch eventType:
case "BatteryIn":
// 1. Record the battery insertion event
swapRecord = database.findOrCreateSwapRecord(requestId, stationId)
swapRecord.idToken = idToken
swapRecord.batteryInTimestamp = now()
swapRecord.insertedBatteries = []
for battery in batteries:
swapRecord.insertedBatteries.push({
evseId: battery.evseId,
serialNumber: battery.serialNumber,
soC: battery.soC,
soH: battery.soH,
productionDate: battery.productionDate
})
// 2. Update slot inventory
database.updateSlot(stationId, battery.evseId, {
status: "Occupied",
batterySerial: battery.serialNumber,
batterySoC: battery.soC,
batterySoH: battery.soH
})
database.saveSwapRecord(swapRecord)
return {} // Empty acknowledgement
case "BatteryOut":
// 1. Record the battery removal event
swapRecord = database.findSwapRecord(requestId, stationId)
swapRecord.batteryOutTimestamp = now()
swapRecord.removedBatteries = []
for battery in batteries:
swapRecord.removedBatteries.push({
evseId: battery.evseId,
serialNumber: battery.serialNumber,
soC: battery.soC,
soH: battery.soH
})
// 2. Update slot inventory
database.updateSlot(stationId, battery.evseId, {
status: "Available",
batterySerial: null,
batterySoC: null,
batterySoH: null
})
// 3. Calculate swap billing
billing.calculateSwapCost(swapRecord)
database.saveSwapRecord(swapRecord)
return {}
case "BatteryOutTimeout":
// The old batteries were not removed in time
swapRecord = database.findSwapRecord(requestId, stationId)
swapRecord.status = "TimedOut"
swapRecord.timeoutBatteries = batteries
for battery in batteries:
database.updateSlot(stationId, battery.evseId, {
status: "Available"
})
database.saveSwapRecord(swapRecord)
return {}function handleNotifyEventRequest(stationId, request):
for event in request.eventData:
component = event.component
variable = event.variable
// Filter for battery slot state changes
if component.name == "Connector" and variable.name == "AvailabilityState":
slotId = component.evse.id
newState = event.actualValue // "Occupied" or "Available"
database.updateSlotAvailability(stationId, slotId, newState)
// Always return empty acknowledgement
return {}Requirements
S03.FR.01 — BatteryIn event
includes the requestId, idToken, and batteryData with evseId set to slot
number where each battery was inserted.
S03.FR.02 — After BatteryIn, BSS
reports each affected connector as Occupied via NotifyEventRequest.
S03.FR.03 — BatteryOut event
uses the same requestId as the BatteryIn event.
This is how CSMS correlates the two halves of a swap.
S03.FR.04 — After BatteryOut, BSS
reports each affected connector as Available via NotifyEventRequest.
S03.FR.05 — If BSS has no (or not enough) batteries available when the EV driver authorizes, the
BSS refuses the swap operation locally.
S03.FR.06 — If old batteries are not removed within BatterySwapOutTimeout,
BSS sends BatteryOutTimeout.
CSMS ends up with an orphan BatteryIn without a
corresponding BatteryOut.
S03.FR.07 — If the BSS uses reverse swap order ("Out-In"), it reports BatterySwapCtrlr.SwapOrder = "Out-In".
7. S04 — Battery Swap Charging
BSS-Initiated| Use Case ID | S04 |
| Direction | BSS → CSMS (BSS initiates) |
| Trigger | After empty batteries are inserted into the BSS (via S03), the BSS charges them |
| OCPP Messages | TransactionEventRequest / TransactionEventResponse |
The CSMS handles TransactionEventRequest for battery charging the same way as normal EV charging, with battery-swap-specific
considerations for token identification and trigger values.
Flow Diagram
BSS CSMS
| |
| [Battery inserted, start charging] |
| |
|--TransactionEventRequest----------->| eventType=Started
| (triggerReason=CablePluggedIn, | idToken.type=NoAuthorization
| chargingState=EVConnected) | OR idToken=BatterySwapIdtoken
|<--TransactionEventResponse----------|
| |
|--TransactionEventRequest----------->| eventType=Updated
| (triggerReason=ChargingStateChanged | chargingState=Charging
| chargingState=Charging) |
|<--TransactionEventResponse----------|
| |
| [loop: periodically send SoC] |
|--TransactionEventRequest----------->| eventType=Updated
| (triggerReason=MeterValuePeriodic, | measurand=SoC
| meterValue=[SoC: XX]) |
|<--TransactionEventResponse----------|
| |
| [When SoC reaches BatterySwapMaxSoc]|
|--TransactionEventRequest----------->| eventType=Updated
| (triggerReason=EnergyLimitReached, | chargingState=SuspendedEVSE
| chargingState=SuspendedEVSE) |
|<--TransactionEventResponse----------|
| |
| [When battery is extracted] |
|--TransactionEventRequest----------->| eventType=Ended
| (triggerReason=EVCommunicationLost, | stoppedReason=EVDisconnected
| chargingState=Idle, |
| stoppedReason=EVDisconnected) |
|<--TransactionEventResponse----------|Transaction Started
Identifying a battery swap charging transaction:
With BatterySwapIdtoken configured
idToken.idToken = value of BatterySwapIdtoken
idToken.type = "Central"
Without BatterySwapIdtoken
idToken.idToken = "" (empty string)
idToken.type = "NoAuthorization"
Important: The BatterySwapRequest idToken belongs to the EV Driver. The TransactionEventRequest idToken is NOT the EV driver — it is either the BatterySwapIdtoken or NoAuthorization.
These are different identity contexts.
{
"eventType": "Started",
"timestamp": "2025-12-03T14:31:00Z",
"triggerReason": "CablePluggedIn",
"seqNo": 0,
"transactionInfo": {
"transactionId": "tx-bss-slot3-20251203",
"chargingState": "EVConnected"
},
"evse": { "id": 3 },
"idToken": {
"idToken": "BSS-INTERNAL-TOKEN-001",
"type": "Central"
}
}{
"eventType": "Started",
"timestamp": "2025-12-03T14:31:00Z",
"triggerReason": "CablePluggedIn",
"seqNo": 0,
"transactionInfo": {
"transactionId": "tx-bss-slot3-20251203",
"chargingState": "EVConnected"
},
"evse": { "id": 3 },
"idToken": {
"idToken": "",
"type": "NoAuthorization"
}
}Periodic SoC Updates
During charging, the BSS sends periodic meter values with measurand = "SoC".
{
"eventType": "Updated",
"timestamp": "2025-12-03T14:45:00Z",
"triggerReason": "MeterValuePeriodic",
"seqNo": 3,
"transactionInfo": {
"transactionId": "tx-bss-slot3-20251203",
"chargingState": "Charging"
},
"evse": { "id": 3 },
"meterValue": [
{
"timestamp": "2025-12-03T14:45:00Z",
"sampledValue": [
{
"value": 45.0,
"measurand": "SoC"
}
]
}
]
}Max SoC Reached (Suspend)
When the battery reaches BatterySwapMaxSoc,
charging is suspended but the transaction stays alive.
{
"eventType": "Updated",
"timestamp": "2025-12-03T16:20:00Z",
"triggerReason": "EnergyLimitReached",
"seqNo": 15,
"transactionInfo": {
"transactionId": "tx-bss-slot3-20251203",
"chargingState": "SuspendedEVSE"
},
"evse": { "id": 3 }
}Important: The transaction is kept alive (not ended) even after reaching max SoC. This allows energy transfer to be restarted (e.g., for V2X operations). The transaction only ends when the battery is physically extracted.
Battery Extracted (End)
When the battery is removed from the slot (given to an EV driver during a swap), the charging transaction ends.
{
"eventType": "Ended",
"timestamp": "2025-12-03T18:00:00Z",
"triggerReason": "EVCommunicationLost",
"seqNo": 20,
"transactionInfo": {
"transactionId": "tx-bss-slot3-20251203",
"chargingState": "Idle",
"stoppedReason": "EVDisconnected",
"timeSpentCharging": 12540
},
"evse": { "id": 3 }
}Implementation Logic
function handleTransactionEventRequest(stationId, request):
txId = request.transactionInfo.transactionId
evseId = request.evse?.id
switch request.eventType:
case "Started":
isBatterySwapCharging = isBatterySwapStation(stationId)
&& (request.idToken?.type == "NoAuthorization"
|| isBatterySwapIdToken(stationId, request.idToken))
database.createTransaction({
transactionId: txId,
stationId: stationId,
evseId: evseId,
startTime: request.timestamp,
isBatterySwapCharging: isBatterySwapCharging,
chargingState: request.transactionInfo.chargingState
})
// Link to slot if this is a BSS
if isBatterySwapCharging:
database.updateSlot(stationId, evseId, {
chargingTransactionId: txId
})
return {} // Empty response is valid
case "Updated":
tx = database.findTransaction(txId)
tx.chargingState = request.transactionInfo.chargingState
tx.lastUpdated = request.timestamp
// Track SoC from meter values
if request.meterValue:
for mv in request.meterValue:
for sv in mv.sampledValue:
if sv.measurand == "SoC":
tx.currentSoC = sv.value
database.updateSlot(stationId, evseId, {
batterySoC: sv.value
})
// Detect suspension at max SoC
if request.triggerReason == "EnergyLimitReached":
tx.chargingState = "SuspendedEVSE"
database.updateSlot(stationId, evseId, {
swapEligible: true
})
database.saveTransaction(tx)
return {}
case "Ended":
tx = database.findTransaction(txId)
tx.endTime = request.timestamp
tx.stoppedReason = request.transactionInfo.stoppedReason
tx.timeSpentCharging = request.transactionInfo.timeSpentCharging
tx.status = "Completed"
// Clear slot charging transaction link
database.updateSlot(stationId, evseId, {
chargingTransactionId: null
})
database.saveTransaction(tx)
return {}Requirements
S04.FR.01 — BSS initiates charging when a battery is inserted. Actual energy transfer may be delayed
(e.g., until after BatterySwapResponse,
or when cheap energy is available).
S04.FR.02 — If BatterySwapIdtoken is
set, the Started event uses idToken.type = "Central".
S04.FR.03 — If BatterySwapIdtoken is
NOT set, idToken.type = "NoAuthorization".
S04.FR.04 — BSS sends periodic Updated events with measurand = "SoC" during charging.
S04.FR.05 — At BatterySwapTargetSoc,
the slot becomes eligible for swapping.
S04.FR.06 — At BatterySwapMaxSoc,
charging is suspended ( chargingState = SuspendedEVSE, triggerReason = EnergyLimitReached).
Transaction stays alive.
S04.FR.07 — When the battery is removed, BSS sends Ended with triggerReason = EVCommunicationLost, stoppedReason = EVDisconnected.
S04.FR.08 — TxStartPoint SHALL be "EVConnected" (transaction starts when battery is inserted).
S04.FR.09 — TxStopPoint SHALL be "EVConnected" (transaction ends when battery is removed).
S04.FR.10 — BatterySwapMaxSoc >= BatterySwapTargetSoc.
S04.FR.11 — On BSS reboot, the BSS starts new charging transactions for all slots that have a
battery, including those already at max SoC.
S04.FR.12 — BSS reports battery SoC per slot via BatteryCartridgeSoC (queryable via GetVariablesRequest).
8. Message Schemas Reference
ReferenceBatterySwapRequest Schema
Direction: BSS → CSMS
{
"type": "object",
"required": ["eventType", "requestId", "idToken", "batteryData"],
"properties": {
"eventType": {
"type": "string",
"enum": ["BatteryIn", "BatteryOut", "BatteryOutTimeout"]
},
"requestId": {
"type": "integer",
"description": "Correlates BatteryIn/Out events and optional RequestBatterySwapRequest"
},
"idToken": {
"type": "object",
"required": ["idToken", "type"],
"properties": {
"idToken": { "type": "string", "maxLength": 255 },
"type": { "type": "string", "maxLength": 20 },
"additionalInfo": {
"type": "array",
"items": {
"type": "object",
"required": ["additionalIdToken", "type"],
"properties": {
"additionalIdToken": { "type": "string", "maxLength": 255 },
"type": { "type": "string", "maxLength": 50 }
}
}
}
}
},
"batteryData": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["evseId", "serialNumber", "soC", "soH"],
"properties": {
"evseId": { "type": "integer", "minimum": 0 },
"serialNumber": { "type": "string", "maxLength": 50 },
"soC": { "type": "number", "minimum": 0.0, "maximum": 100.0 },
"soH": { "type": "number", "minimum": 0.0, "maximum": 100.0 },
"productionDate": { "type": "string", "format": "date-time" },
"vendorInfo": { "type": "string", "maxLength": 500 }
}
}
}
}
}BatterySwapResponse Schema
Direction: CSMS → BSS
{
"type": "object",
"description": "Empty acknowledgement. The request cannot be rejected via standard fields.",
"properties": {
"customData": {
"type": "object",
"required": ["vendorId"],
"properties": {
"vendorId": { "type": "string", "maxLength": 255 }
}
}
}
}Note: The BatterySwapResponse has NO required fields. An empty JSON {} is a valid
response. See Error Handling for the customData rejection extension.
RequestBatterySwapRequest Schema
Direction: CSMS → BSS
{
"type": "object",
"required": ["requestId", "idToken"],
"properties": {
"requestId": {
"type": "integer",
"description": "Request ID to match with BatterySwapRequest"
},
"idToken": {
"type": "object",
"required": ["idToken", "type"],
"properties": {
"idToken": { "type": "string", "maxLength": 255 },
"type": { "type": "string", "maxLength": 20 },
"additionalInfo": {
"type": "array",
"items": {
"type": "object",
"required": ["additionalIdToken", "type"],
"properties": {
"additionalIdToken": { "type": "string", "maxLength": 255 },
"type": { "type": "string", "maxLength": 50 }
}
}
}
}
}
}
}RequestBatterySwapResponse Schema
Direction: BSS → CSMS
{
"type": "object",
"required": ["status"],
"properties": {
"status": {
"type": "string",
"enum": ["Accepted", "Rejected"]
},
"statusInfo": {
"type": "object",
"required": ["reasonCode"],
"properties": {
"reasonCode": { "type": "string", "maxLength": 20 },
"additionalInfo": { "type": "string", "maxLength": 1024 }
}
}
}
}9. Error Handling & Custom Data Extensions
ReferenceRejecting an Inserted Battery
The standard BatterySwapResponse is
an empty acknowledgement with no rejection mechanism. To reject a battery,
CSMS must use a customData extension.
Prerequisite: The BSS must support this extension. Check: CustomizationCtrlr.CustomImplementationEnabled["org.openchargealliance.batteryswapresponse"] = true
customData Extension Schema
{
"customData": {
"vendorId": "org.openchargealliance.batteryswapresponse",
"status": "Accepted | Rejected",
"statusInfo": {
"reasonCode": "<reason>",
"additionalInfo": "<human-readable explanation>"
}
}
}Predefined Reason Codes for Battery Rejection
| reasonCode | Description |
|---|---|
BatterySoHLow | Battery state of health is too low |
BatterySoC | Battery state of charge issue |
BatteryDamaged | Battery is damaged |
BatteryUnknown | Battery is not recognized (not from this CPO) |
BatteryType | Wrong battery type |
NoBatteryAvailable | No batteries available for swap |
{
"customData": {
"vendorId": "org.openchargealliance.batteryswapresponse",
"status": "Rejected",
"statusInfo": {
"reasonCode": "BatteryUnknown",
"additionalInfo": "Not a battery of this CPO"
}
}
}{
"customData": {
"vendorId": "org.openchargealliance.batteryswapresponse",
"status": "Accepted"
}
}BatteryOutTimeout Handling
When the BSS sends eventType = BatteryOutTimeout,
it means:
- The EV driver inserted batteries (CSMS received
BatteryIn) but never took the charged batteries out. - CSMS now has an orphan
BatteryInwithout a matchingBatteryOut. - The CSMS should handle this as an incomplete swap for billing/audit purposes.
BSS Reboot During Charging
Per S04.FR.11: When a BSS reboots, it starts new charging
transactions for all EVSEs that have a battery in the slot. This includes batteries
that have already reached BatterySwapMaxSoc.
The CSMS should:
- Accept these new
TransactionEventRequest(Started)messages. - For batteries already at max SoC, immediately expect a
TransactionEventRequest(Updated)withtriggerReason = EnergyLimitReachedandchargingState = SuspendedEVSE.
10. CSMS State Management
ArchitectureRecommended data models for tracking battery swap sessions, battery records, and slot status on the CSMS side.
SwapSession Model
SwapSession {
id: UUID
stationId: string // Charging Station identity
requestId: integer // Correlation ID across all messages in this swap
idToken: IdTokenType // EV driver's token
initiatedBy: "Local" | "Remote"
remoteRequestSentAt: datetime? // If remote (S02)
// BatteryIn event
batteryInTimestamp: datetime?
insertedBatteries: BatteryRecord[]
// BatteryOut event
batteryOutTimestamp: datetime?
removedBatteries: BatteryRecord[]
// Status
status: "Pending" | "InProgress" | "Completed" | "TimedOut" | "Rejected"
// Billing
swapCost: decimal?
billingCalculated: boolean
}BatteryRecord {
evseId: integer
serialNumber: string
soC: number
soH: number
productionDate: datetime?
vendorInfo: string?
}SlotStatus Model
SlotStatus {
stationId: string
evseId: integer
availabilityState: "Available" | "Occupied" | "Unavailable"
batterySerialNumber: string?
batterySoC: number?
batterySoH: number?
swapEligible: boolean // SoC >= BatterySwapTargetSoc
chargingTransactionId: string?
lastUpdated: datetime
}End-to-End Flow Correlation
The requestId is the
primary correlation key across all battery swap messages:
RequestBatterySwapRequest.requestId ──┐
├── Same requestId
BatterySwapRequest(BatteryIn).requestId ──┤
│
BatterySwapRequest(BatteryOut).requestId ─┘Note: For locally authorized swaps (S01), there is no RequestBatterySwapRequest,
but the requestId still
correlates the BatteryIn and BatteryOut events.
11. Message Direction Summary
ReferenceIncoming to CSMS (BSS → CSMS)
| Incoming Message | Handler Action | Response |
|---|---|---|
AuthorizeRequest | Validate idToken for swap eligibility | AuthorizeResponse with idTokenInfo.status |
BatterySwapRequest (BatteryIn) | Record inserted batteries, update slot inventory | BatterySwapResponse (empty or with customData rejection) |
BatterySwapRequest (BatteryOut) | Record removed batteries, complete swap, trigger billing | BatterySwapResponse (empty) |
BatterySwapRequest (BatteryOutTimeout) | Mark swap as timed out, handle orphan BatteryIn | BatterySwapResponse (empty) |
NotifyEventRequest (AvailabilityState) | Update slot availability state | NotifyEventResponse (empty) |
TransactionEventRequest (Started) | Create charging transaction for battery in slot | TransactionEventResponse |
TransactionEventRequest (Updated) | Update SoC, detect charging state changes | TransactionEventResponse |
TransactionEventRequest (Ended) | Close charging transaction | TransactionEventResponse |
RequestBatterySwapResponse | Process acceptance/rejection of remote swap | N/A (this IS the response) |
Outgoing from CSMS (CSMS → BSS)
| Outgoing Message | When to Send | Expected Response |
|---|---|---|
RequestBatterySwapRequest | User requests remote swap via app | RequestBatterySwapResponse (Accepted/Rejected) |
Quick Reference — All Enums
BatterySwapEventEnumType
BatteryIn | BatteryOut | BatteryOutTimeout
GenericStatusEnumType
Accepted | Rejected
AuthorizationStatusEnumType
Accepted | Blocked | ConcurrentTx | Expired | Invalid | NoCredit | NotAllowedTypeEVSE | NotAtThisLocation | NotAtThisTime | Unknown
TransactionEventEnumType
Started | Updated | Ended
TriggerReasonEnumType (S04)
CablePluggedIn | ChargingStateChanged | MeterValuePeriodic | EnergyLimitReached | EVCommunicationLost
ChargingStateEnumType (S04)
EVConnected | Charging | SuspendedEVSE | SuspendedEV | Idle
Battery Rejection Reason Codes
BatterySoHLow | BatterySoC | BatteryDamaged | BatteryUnknown | BatteryType | NoBatteryAvailable
stoppedReason (S04)
EVDisconnected