Skip to content

feat: add OCPP 1.2 support and OCPP 1.5 API adapter - #98

Open
juherr wants to merge 13 commits into
IZIVIA:devfrom
juherr:juherr/implement-ocpp-1-2
Open

feat: add OCPP 1.2 support and OCPP 1.5 API adapter#98
juherr wants to merge 13 commits into
IZIVIA:devfrom
juherr:juherr/implement-ocpp-1-2

Conversation

@juherr

@juherr juherr commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Closes #84

Summary

Adds first-class support for OCPP 1.2 (SOAP only, as requested in #84) and completes the OCPP 1.5 API adapter, so applications can talk to 1.2/1.5 charge points and CSMS through the same version-agnostic generic API already used for 1.6 and 2.0. This rounds out the library's version coverage — one of the goals in #84 (positioning it as a base library for a Steve-style OSS CPMS).

Previously ApiFactory threw NotImplementedError("Ocpp 1.5 api adapter not yet implemented") and had no 1.2 path at all. Both are now wired end to end.

What's included

  • OCPP 1.2 SOAP (ocpp-1-2-soap, ocpp-1-2-core, ocpp-1-2-api, ocpp-1-2-api-adapter): SOAP parser, core model and the Ocpp12Adapter / Ocpp12CSApiAdapter translation layer.
  • OCPP 1.5 API adapter (ocpp-1-5-api-adapter): Ocpp15Adapter plus the full set of request/response mappers, mirroring the mature 1.6 adapter.
  • Integration wiring:
    • OcppVersion enum gains OCPP_1_2("ocpp1.2").
    • ApiFactory / CSMS now build 1.2 and 1.5 adapters (the when over versions is exhaustive).
    • Settings.newMessageId: () -> String (default UUID.randomUUID) makes message-id generation injectable — removes static UUID mocking from tests.

Generic → version mapping: graceful degradation

The generic model (2.0-shaped) is richer than the 1.2/1.5 wire models. Every enum conversion is now exhaustive and explicit (no Enum.valueOf(name) fallback), so the compiler forces each value to be handled and a new generic value can't silently crash at runtime. Values with no target equivalent are handled deliberately:

  • Error codes with no equivalent → Mode3Error (1.2, no OtherError exists) / OtherError (1.5), with a warn log.
  • Connector Reserved (1.2 only) → Unavailable.
  • RebootRequired config status → Accepted.
  • Transient firmware/diagnostics states (Downloading, Installing, Uploading, Idle, …) have no terminal 1.x equivalent → the adapter skips them (isSupported), logs a warn, and returns RequestStatus.NOT_SEND instead of forwarding a misleading value.
  • Charging states (Charging, SuspendedEV, SuspendedEVSE) → Occupied.

Testing

  • Adapter/mapper unit tests for 1.2 and 1.5 (round-trip mapping, enum downgrade, transient-state filtering, unsupported-operation rejection).
  • Toolkit factory tests split per version (Ocpp12FactoryTest, Ocpp15FactoryTest) over a shared SOAP helper.
  • ./gradlew :ocpp-1-2-api-adapter:test :ocpp-1-5-api-adapter:test :toolkit:test → green.

juherr added 9 commits July 7, 2026 17:14
Replace fragile `Enum.valueOf(name)` fallbacks in the 1.2/1.5 adapter
mappers with exhaustive, explicit when-branches so the compiler enforces
that every generic enum value is handled and no new value can crash at
runtime.

Values with no 1.x equivalent are downgraded deliberately and logged:
- error codes -> Mode3Error (1.2, no OtherError) / OtherError (1.5)
- connector Reserved -> Unavailable (1.2)
- RebootRequired config status -> Accepted
- charging states (Charging, SuspendedEV, SuspendedEVSE) -> Occupied

Transient firmware/diagnostics states (Downloading, Installing, Uploading,
Idle, ...) have no terminal 1.x representation, so the adapter skips them
(warn + RequestStatus.NOT_SEND) instead of forwarding a misleading value.

Also fix the notifyEVChargingSchedule exception message in Ocpp12Adapter.
Adds adapter/mapper unit tests for all of the above.

@pbourseau pbourseau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revue de la PR #98 (OCPP 1.2 + adaptateur 1.5). Belle contribution, cohérente avec l'adaptateur 1.6 existant : when exhaustifs sur les enums, dégradations explicites et loguées, tests par version. Quelques points ci-dessous en commentaires en ligne — aucun n'est bloquant, mais #1 (StopTransaction qui lève une exception si l'état est perdu) et #2 (filtrage MeterValues incohérent) méritent d'être traités ou explicitement assumés avant merge.

): OperationExecution<TransactionEventReq, TransactionEventResp> {
val mapper: StopTransactionMapper = Mappers.getMapper(StopTransactionMapper::class.java)
val transactionId =
transactionIds.getTransactionIdsByLocalId(request.transactionInfo.transactionId).csmsId

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robustesse — StopTransaction lève une exception si l'état est perdu. Contrairement à meterValues (qui attrape l'IllegalStateException et renvoie NOT_SEND), ce lookup n'est pas protégé. getTransactionIdsByLocalId lève IllegalStateException("key … not found") : un StopTransaction dont le Start n'a pas été enregistré — typiquement après un redémarrage, puisque RealTransactionRepository est en mémoire — fait remonter l'exception.

Soit attraper l'exception de façon cohérente (comme dans meterValues), soit documenter que le repository par défaut n'est pas persistant et que l'appelant doit injecter sa propre implémentation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documenté plutôt qu'attrapé : RealTransactionRepository porte désormais un KDoc précisant qu'il est en mémoire / non persistant et qu'il faut injecter une implémentation persistante pour survivre à un redémarrage. À la différence de meterValues, avaler un StopTransaction (→ NOT_SEND) risquerait de perdre silencieusement une fin de session/facturation. (d55edde)

if (request.transactionInfo.chargingState != null) {
// Add 1ms to the timestamp so that the statusNotification request timestamp
// is the latest one compare to the previous request timestamp
request.timestamp = request.timestamp.plus(1, DateTimeUnit.MILLISECOND)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effet de bord — mutation de l'argument d'entrée. request.timestamp est muté en place, alors que la même instance request est ensuite renvoyée dans l'OperationExecution. Muter un argument d'entrée est un smell ; préférer une copie (request.copy(timestamp = …)).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrigé : on copie la requête (request.copy(timestamp = …)) au lieu de muter l'argument, dans updateStatusEvent (1.2 et 1.5). (d55edde)

val meterValue = meterValuesReq.meterValue
val meterValueList = meterValue.map { (s, t) ->
MeterValue(
value = s.singleOrNull { it.measurand == MeasurandEnumType.EnergyActiveImportRegister }?.value?.toInt()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cohérence — deux règles de filtrage MeterValues divergentes. Ici : singleOrNull { measurand == EnergyActiveImportRegister }, sans filtre de contexte, et on exige exactement un. À l'inverse CommonMapper.filterMeterValues (utilisé par Start/Stop) filtre par contexte + measurand et tolère « au plus un ».

Pour un même concept (EnergyActiveImportRegister), une même entrée peut être acceptée sur un chemin et rejetée sur l'autre. À unifier.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constat juste. Je préfère ne pas unifier dans cette PR : le chemin MeterValues n'a pas de ReadingContext fixe (contrairement à Start/Stop qui filtrent sur Transaction.Begin/End), donc aligner sur filterMeterValues changerait les valeurs acceptées/rejetées et mérite son propre changement avec des tests dédiés. À traiter en suivi.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unifié (option A) : la sélection de la valeur EnergyActiveImportRegister est extraite dans CommonMapper.singleEnergyRegister(sampledValues, context?). Le chemin MeterValues l'appelle sans contexte, Start/Stop (filterMeterValues) avec TransactionBegin/End ; la règle mesurande + cardinalité est désormais commune, donc un même jeu de sampled values est accepté/rejeté de façon cohérente. Pas de changement de comportement, + un test dédié (0/1/n valeurs, avec et sans contexte). (45988b1)

req: RemoteStartTransactionReq
): OperationExecution<RemoteStartTransactionReq, RemoteStartTransactionResp> {
val mapper: RemoteStartTransactionMapper = Mappers.getMapper(RemoteStartTransactionMapper::class.java)
val remoteStartId: Int = Random.nextInt()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec — remoteStartId peut être négatif. Random.nextInt() couvre tout l'intervalle Int, y compris les valeurs négatives, alors que remoteStartId OCPP est attendu positif. Utiliser p.ex. Random.nextInt(1, Int.MAX_VALUE).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrigé : Random.nextInt(1, Int.MAX_VALUE). (d55edde)

import java.util.concurrent.ConcurrentHashMap

class RealTransactionRepository : TransactionRepository {
val hashMap: ConcurrentHashMap<String, Int> = ConcurrentHashMap()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Encapsulation. hashMap est public : il expose l'état mutable interne. Le passer en private.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrigé : hashMap passé en private. (d55edde)

status !in unsupportedStatuses

@Named("convertDiagnosticsStatus")
fun convertFirmwareStatus(status: UploadLogStatusEnumType): DiagnosticsStatus =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nommage (copier-coller). La fonction s'appelle convertFirmwareStatus alors qu'elle convertit un statut diagnostics. Le KDoc de unsupportedStatuses plus haut mentionne aussi « convertFirmwareStatus ». À renommer en convertDiagnosticsStatus pour la clarté.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrigé : renommé convertDiagnosticsStatus (+ KDoc de unsupportedStatuses mis à jour), 1.2 et 1.5. (d55edde)

OcppVersionTransport.OCPP_1_6 -> Ocpp16SoapParser()
OcppVersionTransport.OCPP_1_5 -> Ocpp15SoapParser()
OcppVersionTransport.OCPP_1_2 -> Ocpp12SoapParser()
else -> TODO("Not yet implemented")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO() résiduel. Ce when retombe sur TODO("Not yet implemented") pour OCPP_2_0, qui lève NotImplementedError à l'exécution — alors que la PR met en avant l'exhaustivité. Latent (2.0 est websocket-only), mais un IllegalArgumentException("OCPP 2.0 has no SOAP transport") explicite serait plus clair qu'un TODO.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrigé : getSoapParser gère explicitement OCPP_2_0 -> throw IllegalArgumentException("OCPP 2.0 has no SOAP transport") ; le when est désormais exhaustif (plus de else/TODO). (d55edde)

): OperationExecution<TransactionEventReq, TransactionEventResp> {
val mapper: StopTransactionMapper = Mappers.getMapper(StopTransactionMapper::class.java)
val transactionId =
transactionIds.getTransactionIdsByLocalId(request.transactionInfo.transactionId).csmsId

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robustesse (idem 1.2). Même remarque que pour Ocpp12Adapter : ce lookup non protégé lève une IllegalStateException si le Start n'a pas été enregistré (repository en mémoire, perdu au redémarrage). À attraper ou à documenter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idem 1.2 : documenté sur RealTransactionRepository (1.5). (d55edde)

req: RemoteStartTransactionReq
): OperationExecution<RemoteStartTransactionReq, RemoteStartTransactionResp> {
val mapper: RemoteStartTransactionMapper = Mappers.getMapper(RemoteStartTransactionMapper::class.java)
val remoteStartId: Int = Random.nextInt()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec (idem 1.2). Random.nextInt() peut produire un remoteStartId négatif ; utiliser Random.nextInt(1, Int.MAX_VALUE).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrigé (idem 1.2) : Random.nextInt(1, Int.MAX_VALUE). (d55edde)

import java.util.concurrent.ConcurrentHashMap

class RealTransactionRepository : TransactionRepository {
val hashMap: ConcurrentHashMap<String, Int> = ConcurrentHashMap()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Encapsulation (idem 1.2). hashMap devrait être private.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrigé (idem 1.2) : hashMap en private. (d55edde)

juherr added 3 commits July 20, 2026 17:29
- encapsulate RealTransactionRepository.hashMap (private) and document its
  in-memory, non-persistent nature (1.2/1.5)
- avoid mutating the input TransactionEventReq: copy before bumping the
  timestamp in updateStatusEvent (1.2/1.5)
- generate a positive remoteStartId with Random.nextInt(1, Int.MAX_VALUE)
- rename DiagnosticsStatusNotificationMapper.convertFirmwareStatus to
  convertDiagnosticsStatus
- replace the residual TODO() in getSoapParser with an explicit
  IllegalArgumentException for OCPP 2.0 (websocket-only)
Extract CommonMapper.singleEnergyRegister as the single rule for picking the
EnergyActiveImportRegister reading, with an optional reading context. Both the
MeterValues path (context-agnostic) and the Start/Stop path (context-scoped,
via filterMeterValues) now select and validate the reading identically, so the
same sampled-value set is accepted/rejected consistently.

No behavior change; adds a mapper test covering the helper (0/1/n matches, with
and without a context).
Random.nextInt() can return negative values, whereas an OCPP remoteStartId
is expected to be positive. Use Random.nextInt(1, Int.MAX_VALUE), consistent
with the OCPP 1.2/1.5 CS-API adapters.
@juherr
juherr force-pushed the juherr/implement-ocpp-1-2 branch from 36a585d to 3feea0e Compare July 20, 2026 16:14
Remove redundant non-null assertions (!!) that are guarded by a preceding
null check, using a local val + isNullOrEmpty or ?.let
(GetConfigurationMapper, Ocpp15CSApiAdapter, StopTransactionMapper).

Replace the if/throw guard in ChangeConfigurationMapper.genToCoreResp with
check(...) in 1.2 and 1.5 — same IllegalStateException and message, more
idiomatic Kotlin.
@sonarqubecloud

Copy link
Copy Markdown

@juherr
juherr requested a review from pbourseau July 20, 2026 18:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[REQUEST] Add support for OCPP 1.2

2 participants