feat: add OCPP 1.2 support and OCPP 1.5 API adapter - #98
Conversation
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
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 = …)).
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
Corrigé : Random.nextInt(1, Int.MAX_VALUE). (d55edde)
| import java.util.concurrent.ConcurrentHashMap | ||
|
|
||
| class RealTransactionRepository : TransactionRepository { | ||
| val hashMap: ConcurrentHashMap<String, Int> = ConcurrentHashMap() |
There was a problem hiding this comment.
Encapsulation. hashMap est public : il expose l'état mutable interne. Le passer en private.
There was a problem hiding this comment.
Corrigé : hashMap passé en private. (d55edde)
| status !in unsupportedStatuses | ||
|
|
||
| @Named("convertDiagnosticsStatus") | ||
| fun convertFirmwareStatus(status: UploadLogStatusEnumType): DiagnosticsStatus = |
There was a problem hiding this comment.
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é.
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
Spec (idem 1.2). Random.nextInt() peut produire un remoteStartId négatif ; utiliser Random.nextInt(1, Int.MAX_VALUE).
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
Encapsulation (idem 1.2). hashMap devrait être private.
There was a problem hiding this comment.
Corrigé (idem 1.2) : hashMap en private. (d55edde)
- 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.
36a585d to
3feea0e
Compare
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.
|



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
ApiFactorythrewNotImplementedError("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-core,ocpp-1-2-api,ocpp-1-2-api-adapter): SOAP parser, core model and theOcpp12Adapter/Ocpp12CSApiAdaptertranslation layer.ocpp-1-5-api-adapter):Ocpp15Adapterplus the full set of request/response mappers, mirroring the mature 1.6 adapter.OcppVersionenum gainsOCPP_1_2("ocpp1.2").ApiFactory/CSMSnow build 1.2 and 1.5 adapters (thewhenover versions is exhaustive).Settings.newMessageId: () -> String(defaultUUID.randomUUID) makes message-id generation injectable — removes staticUUIDmocking 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:Mode3Error(1.2, noOtherErrorexists) /OtherError(1.5), with awarnlog.Reserved(1.2 only) →Unavailable.RebootRequiredconfig status →Accepted.Downloading,Installing,Uploading,Idle, …) have no terminal 1.x equivalent → the adapter skips them (isSupported), logs awarn, and returnsRequestStatus.NOT_SENDinstead of forwarding a misleading value.Charging,SuspendedEV,SuspendedEVSE) →Occupied.Testing
Ocpp12FactoryTest,Ocpp15FactoryTest) over a shared SOAP helper../gradlew :ocpp-1-2-api-adapter:test :ocpp-1-5-api-adapter:test :toolkit:test→ green.