OCPP 2.x: Costruire Sistemi di Ricarica EV Enterprise
Il mercato globale dell'infrastruttura di ricarica per veicoli elettrici ha raggiunto $40,22 miliardi nel 2025 e crescera a un CAGR del 25% fino al 2033, secondo Grand View Research. In Europa, la regolamentazione AFIR (Alternative Fuels Infrastructure Regulation) impone deadline vincolanti: ogni 60 km sulla rete TEN-T deve essere presente una stazione da almeno 150 kW entro fine 2025. In Italia, con oltre 73.000 punti di ricarica pubblici al 31 dicembre 2025 e un PNRR che ha stanziato oltre 700 milioni di euro per High Power Charging, il settore e in piena esplosione.
Al centro di questa infrastruttura c'è l'Open Charge Point Protocol (OCPP), lo standard aperto sviluppato dalla Open Charge Alliance (OCA) che definisce come le stazioni di ricarica (Charging Stations) comunicano con i sistemi di gestione centralizzati (CSMS - Charging Station Management Systems). Adottato da oltre 250 organizzazioni in più di 40 paesi, OCPP e diventato lo standard de-facto nell'industria, garantendo interoperabilità tra hardware di produttori diversi e software di gestione differenti.
In questo articolo tecnico avanzato esploriamo OCPP 2.0.1 e 2.1 in profonditia: architettura WebSocket, struttura dei messaggi, Device Model, profili di sicurezza, smart charging e come costruire un backend CSMS production-ready scalabile da 10 a 100.000 stazioni, con esempi di codice completi in TypeScript e Python.
Cosa Imparerai
- L'evoluzione del protocollo OCPP da 1.2 a 2.1 e le differenze architetturali fondamentali
- La struttura dei messaggi JSON (CALL, CALLRESULT, CALLERROR) e il trasporto WebSocket
- I 16 blocchi funzionali di OCPP 2.0.1 e il Device Model gerarchico
- I 3 profili di sicurezza: Basic Auth, TLS, mTLS con certificati X.509
- Smart charging avanzato: SetChargingProfile, load management, peak shaving, integrazione solare
- Implementazione backend CSMS in Python con asyncio e PostgreSQL
- ISO 15118 e Plug & Charge: PKI, eMAID, V2G bidirezionale
- Architettura scalabile: da 10 a 100.000 charge points con WebSocket clustering e Kafka
- Monitoring, Grafana dashboard e metriche operative chiave
- Normativa AFIR, PNIRE e incentivi italiani per l'infrastruttura di ricarica
La Serie EnergyTech: 10 Articoli sull'Energia Digitale
Questo articolo e il primo di una serie dedicata all'EnergyTech: protocolli, architetture e software che stanno rivoluzionando la gestione dell'energia elettrica, dalla ricarica EV alle smart grid, dai sistemi BESS all'ottimizzazione energetica con AI.
| # | Articolo | Tecnologie | Livello |
|---|---|---|---|
| 1 | Protocollo OCPP 2.x: Costruire Sistemi di Ricarica EV (sei qui) | OCPP, WebSocket, Python, ISO 15118 | Advanced |
| 2 | Smart Grid e OpenADR: Demand Response e Flessibilità Energetica | OpenADR, IEEE 2030.5, REST, MQTT | Advanced |
| 3 | BESS (Battery Energy Storage): Algoritmi di Ottimizzazione e BMS | Python, LP optimization, CAN bus, Modbus | Advanced |
| 4 | Digital Twin per Reti Elettriche con Kafka e Machine Learning | Kafka, InfluxDB, Grafana, ML, Python | Advanced |
| 5 | SCADA e ICS per Infrastrutture Critiche: Sicurezza e Protocolli | Modbus, DNP3, IEC 61850, OPC UA | Advanced |
| 6 | Ottimizzazione Energetica con AI: Previsione del Consumo e Demand Forecasting | TensorFlow, LSTM, Prophet, FastAPI | Advanced |
| 7 | Virtual Power Plant: Aggregare DER con Python e API REST | DER, DERMS, REST, Python, PostgreSQL | Intermediate |
| 8 | Mercati Energetici e Trading Algoritmico: EPEX SPOT e API | Python, API trading, time series | Advanced |
| 9 | Carbon Accounting Software: Scope 1, 2, 3 e Reporting GHG | Python, GHG Protocol, API, reporting | Intermediate |
| 10 | Microgrids e Isola Energetica: Architetture Resiliente | Microgrid, EMS, edge computing, IoT | Advanced |
Evoluzione del Protocollo: Da OCPP 1.2 a 2.1
Comprendere l'evoluzione di OCPP e fondamentale per apprezzare le scelte architetturali della versione 2.0.1 e pianificare migrazioni da sistemi legacy. Il protocollo e nato nel 2010 per risolvere il problema dell'interoperabilità: ogni produttore di stazioni aveva il proprio protocollo proprietario, rendendo impossibile la gestione multi-vendor.
| Versione | Anno | Trasporto | Caratteristiche Chiave | Deployment |
|---|---|---|---|---|
| OCPP 1.2 | 2010 | SOAP/XML | Prima versione pubblica, operazioni base: boot, authorize, start/stop | Dismesso |
| OCPP 1.5 | 2012 | SOAP/XML | Riservazione, smart charging base, data transfer, reset | Legacy |
| OCPP 1.6 | 2015 | SOAP + JSON/WS | WebSocket, profili di carica, trigger messages, local auth list | Diffusissimo |
| OCPP 2.0 | 2018 | JSON/WS | Device Model, blocchi funzionali, ISO 15118 base (sostituito da 2.0.1) | Raro |
| OCPP 2.0.1 | 2020 | Solo JSON/WS | 16 blocchi funzionali, Device Model, 3 security profile, smart charging avanzato | Standard attuale |
| OCPP 2.1 | 2025 | Solo JSON/WS | Retrocompatibile 2.0.1, V2G ISO 15118-20, tariffazione nativa, battery swap | Emergente |
Le Differenze Fondamentali tra OCPP 1.6 e 2.0.1
OCPP 2.0.1 non e un semplice aggiornamento incrementale: e una riscrittura architetturale completa che cambia terminologia, struttura dei messaggi e modello concettuale. Questa incompatibilita "by design" e stata necessaria per superare i limiti strutturali di OCPP 1.6.
| Aspetto | OCPP 1.6 | OCPP 2.0.1 |
|---|---|---|
| Terminologia server | Central System | CSMS (Charging Station Management System) |
| Terminologia client | Charge Point | Charging Station |
| Unita di ricarica | Connector | EVSE (Electric Vehicle Supply Equipment) |
| Transazioni | StartTransaction / StopTransaction | TransactionEvent unificato (Started/Updated/Ended) |
| Configurazione | Chiavi fisse (ChangeConfiguration) | Device Model gerarchico (GetVariables/SetVariables) |
| Sicurezza | Opzionale, non standardizzata | 3 Security Profile integrati e obbligatori |
| Smart Charging | Base (profili per connettore) | Avanzato: per EVSE, priorità stack, composite schedules |
| ISO 15118 | Non supportato | Blocco M nativo (Plug & Charge) |
| Organizzazione spec | Lista piatta di operazioni | 16 blocchi funzionali con use case, requisiti, diagrammi |
OCPP 2.1: Le Novità del 2025
Rilasciato nel gennaio 2025 dalla Open Charge Alliance, OCPP 2.1 mantiene piena retrocompatibilita con 2.0.1 e aggiunge funzionalità critiche per il futuro:
- V2G (Vehicle-to-Grid) avanzato: supporto completo per ISO 15118-20 con trasferimento bidirezionale di energia, abilitando gli EV come virtual power plant
- Integrazione DER: Advanced tools per ottimizzazione energetica distribuita con risorse come pannelli fotovoltaici e sistemi di accumulo
- Tariffazione nativa: strutture dati standardizzate per comunicare tariffe in tempo reale (kWh, tempo, fee parcheggio) senza estensioni vendor-specific
- Battery Swap: supporto per stazioni di scambio batterie per veicoli a due e tre ruote
- Resume Transaction: possibilità di riprendere una transazione dopo un reboot forzato senza perdita dati
- Costo locale: calcolo del costo direttamente sulla stazione per casi offline
Architettura di Comunicazione WebSocket
OCPP 2.0.1 utilizza esclusivamente JSON over WebSocket come protocollo di trasporto, abbandonando completamente SOAP/XML. Questa scelta architetturale offre comunicazione bidirezionale persistente, bassa latenza, payload leggero e compatibilità nativa con le infrastrutture web moderne.
Topologia Client-Server
Nel modello OCPP, la Charging Station agisce come client WebSocket e il CSMS come server WebSocket. La stazione di ricarica inizia la connessione e la mantiene attiva con un meccanismo di heartbeat. Il CSMS può inviare comandi alla stazione sulla stessa connessione WebSocket aperta, senza necessità di una connessione inversa (no polling, no push separato).
Charging Station CSMS
| |
|--- WebSocket CONNECT ---------------->|
| wss://csms.example.com/ocpp/CS001 |
| Sec-WebSocket-Protocol: ocpp2.0.1 |
| Authorization: Basic base64(...) |
| |
|<-- HTTP 101 Switching Protocols -------|
| Sec-WebSocket-Protocol: ocpp2.0.1 |
| |
|--- BootNotification.req ------------->|
|<-- BootNotification.conf --------------|
| (interval: 300, status: Accepted) |
| |
|--- StatusNotification.req[EVSE1] ---->|
|<-- StatusNotification.conf ------------|
| |
|--- Heartbeat.req (ogni 300s) -------->|
|<-- Heartbeat.conf --------------------|
| |
| <-- utente avvicina RFID --- |
|--- Authorize.req -------------------->|
|<-- Authorize.conf (Accepted) ----------|
| |
|--- TransactionEvent(Started) -------->|
|<-- TransactionEvent.conf -------------|
| |
|<-- SetChargingProfile.req ------------| (CSMS gestisce load)
|--- SetChargingProfile.conf ---------->|
| |
|--- MeterValues (ogni 60s) ----------->|
|<-- MeterValues.conf ------------------|
| |
|--- TransactionEvent(Ended) ---------->|
|<-- TransactionEvent.conf -------------|
URL di Connessione e Subprotocol
La stazione di ricarica si connette al CSMS con un URL che include il proprio
identificatore unico come ultimo segmento del path.
Il subprotocol WebSocket ocpp2.0.1 viene negoziato durante l'handshake
HTTP per garantire la compatibilità della versione del protocollo.
# Formato URL
wss://csms.example.com/ocpp/{chargingStationId}
# Esempi reali
wss://csms.example.com/ocpp/IT-MIL-STATION-001
wss://csms.example.com/ocpp/EVSE-PARK-NORD-042
wss://csms.example.com/ocpp/CPO-AUTOGRILL-A7-01
# Headers WebSocket obbligatori
Sec-WebSocket-Protocol: ocpp2.0.1
Authorization: Basic {base64(stationId:password)} # Security Profile 1-2
# Con Security Profile 3 (mTLS): nessun header Authorization,
# l'autenticazione avviene tramite certificato client TLS
Struttura dei Messaggi OCPP 2.0.1
OCPP 2.0.1 definisce tre tipi di messaggi JSON, tutti trasportati come frame di testo WebSocket. Ogni messaggio e un array JSON con un formato preciso basato sul tipo di messaggio. Questa struttura semplice facilità il parsing e il debugging rispetto all'overhead di SOAP/XML.
CALL (Request) - MessageTypeId 2
Il messaggio CALL rappresenta una richiesta inviata da una parte (Charging Station o CSMS) all'altra. Contiene un ID univoco, il nome dell'azione e il payload della richiesta.
// Formato: [MessageTypeId, MessageId, Action, Payload]
// Esempio: BootNotification dalla Charging Station
[2, "19223201", "BootNotification", {
"chargingStation": {
"model": "SuperCharger-500",
"vendorName": "EVPower Inc.",
"serialNumber": "SN-2025-00142",
"firmwareVersion": "3.2.1",
"modem": {
"iccid": "8939100000000000001",
"imsi": "310260000000001"
}
},
"reason": "PowerUp"
}]
// Esempio: TransactionEvent dalla Charging Station
[2, "tx-evt-001", "TransactionEvent", {
"eventType": "Started",
"timestamp": "2026-03-09T10:30:00Z",
"triggerReason": "CablePluggedIn",
"seqNo": 0,
"transactionInfo": {
"transactionId": "TXN-2026-0309-001",
"chargingState": "EVConnected"
},
"evse": { "id": 1, "connectorId": 1 },
"idToken": {
"idToken": "RFID-04A2B3C4D5",
"type": "ISO14443"
}
}]
CALLRESULT (Response) - MessageTypeId 3
Il messaggio CALLRESULT e la risposta positiva a un CALL. Il MessageId deve corrispondere esattamente a quello del CALL originale per consentire la correlazione richiesta-risposta.
// Formato: [MessageTypeId, MessageId, Payload]
// Risposta a BootNotification
[3, "19223201", {
"currentTime": "2026-03-09T10:00:00Z",
"interval": 300,
"status": "Accepted"
}]
// Risposta a TransactionEvent (Started)
[3, "tx-evt-001", {
"totalCost": 0,
"chargingPriority": 0,
"idTokenInfo": {
"status": "Accepted",
"groupIdToken": {
"idToken": "GROUP-FLEET-01",
"type": "Central"
}
}
}]
CALLERROR (Error) - MessageTypeId 4
Il messaggio CALLERROR viene inviato quando il destinatario non può elaborare un CALL. Include un codice di errore standardizzato, una descrizione leggibile e dettagli strutturati.
// Formato: [MessageTypeId, MessageId, ErrorCode, ErrorDescription, ErrorDetails]
[4, "19223201", "FormatViolation",
"Il campo 'vendorName' supera la lunghezza massima di 50 caratteri",
{
"field": "chargingStation.vendorName",
"maxLength": 50,
"actualLength": 67
}
]
// Codici di errore OCPP 2.0.1 standardizzati:
// FormatViolation - messaggio JSON malformato
// GenericError - errore generico non classificabile
// InternalError - errore interno del ricevente
// MessageTypeNotSupported - tipo di messaggio non supportato
// NotImplemented - azione riconosciuta ma non implementata
// NotSupported - azione non supportata dall'implementazione
// OccurrenceConstraintViolation - violazione cardinalita elementi
// PropertyConstraintViolation - vincolo su una proprietà violato
// ProtocolError - violazione del protocollo OCPP
// RpcFrameworkError - errore nel framework RPC di base
// SecurityError - errore di sicurezza o autenticazione
// TypeConstraintViolation - tipo di dato non corretto
Correlazione Request-Response: Regole Critiche
Ogni CALL deve avere un MessageId univoco (massimo 36 caratteri alfanumerici) che non sia stato usato precedentemente sulla stessa connessione dallo stesso mittente. Il CALLRESULT o CALLERROR deve usare lo stesso MessageId. Il mittente deve mantenere un timeout (consigliato: 30 secondi) dopo il quale la richiesta viene considerata fallita. Solo un CALL può essere pendente alla volta per ogni direzione della comunicazione: la stazione non può inviare un secondo CALL finchè non ha ricevuto risposta al primo.
I 16 Blocchi Funzionali di OCPP 2.0.1
OCPP 2.0.1 organizza tutte le funzionalità in 16 blocchi funzionali (dalla A alla P), ognuno contenente use case specifici con requisiti dettagliati, pre-condizioni e diagrammi di sequenza. Questa organizzazione modulare permette agli implementatori di dichiarare quali blocchi supportano e ai tester di verificare la conformità blocco per blocco.
| Blocco | Nome | Messaggi Principali | Obbligatorio |
|---|---|---|---|
| A | Security | SecurityEventNotification, SignCertificate, CertificateSigned | Si |
| B | Provisioning | BootNotification, SetVariables, GetVariables, NotifyReport | Si |
| C | Authorization | Authorize, ClearCache, GetLocalListVersion | Si |
| D | Local Authorization List | SendLocalList, GetLocalListVersion | No |
| E | Transaction | TransactionEvent, GetTransactionStatus, MeterValues | Si |
| F | Remote Control | RequestStartTransaction, RequestStopTransaction, UnlockConnector | No |
| G | Availability | StatusNotification, ChangeAvailability, Heartbeat | Si |
| H | Reservation | ReserveNow, CancelReservation | No |
| I | Tariff and Cost | CostUpdated, ShowMessage | No |
| J | Metering | MeterValues (misurazioni energy/power/current) | Si |
| K | Smart Charging | SetChargingProfile, ClearChargingProfile, GetChargingProfiles, ReportChargingProfiles | No |
| L | Firmware Management | UpdateFirmware, FirmwareStatusNotification | No |
| M | ISO 15118 Certificate Mgmt | Get15118EVCertificate, DeleteCertificate, CertificateSigned | No |
| N | Diagnostics | GetLog, LogStatusNotification, SetMonitoringBase, SetVariableMonitoring | No |
| O | Display Message | SetDisplayMessage, GetDisplayMessages, ClearDisplayMessage | No |
| P | Data Transfer | DataTransfer (vendor-specific extensions) | No |
Device Model: Il Cuore di OCPP 2.0.1
Il Device Model e la principale innovazione architetturale di OCPP 2.0.1. Sostituisce il sistema rigido di chiavi di configurazione di OCPP 1.6 (ChangeConfiguration con chiavi fisse) con un modello gerarchico flessibile basato su Componenti e Variabili. Ogni stazione descrive completamente la propria struttura e configurazione in modo vendor-agnostic.
ChargingStation (radice)
|
+-- Controller (computer della stazione)
| +-- Variables: Vendor, Model, FirmwareVersion, SerialNumber
|
+-- EVSE[1] (punto di ricarica 1)
| +-- Variables: AvailabilityState, Power, SupplyPhases
| +-- Connector[1] (connettore CCS2 / DC)
| | +-- Variables: ConnectorType, AvailabilityState, MaxCurrent
| +-- Connector[2] (connettore CHAdeMO)
| +-- Variables: ConnectorType, AvailabilityState, MaxCurrent
|
+-- EVSE[2] (punto di ricarica 2)
| +-- Connector[1] (connettore Type2 AC / 22kW)
| +-- Variables: ConnectorType, Phases, MaxCurrent
|
+-- PowerMeter (contatore principale)
| +-- Variables: Energy.Active.Import.Register, Power.Active.Import
|
+-- NetworkInterface (ETH0/LTE)
| +-- Variables: Type, SSID, SignalStrength, ActiveNetworkProfile
|
+-- SecurityCtrlr
| +-- Variables: SecurityProfile, CertificateEntries
|
+-- SmartChargingCtrlr
+-- Variables: ChargingProfileMaxStackLevel, ChargeProfileKindsSupported
// CALL dal CSMS: legge stato EVSE e tipo connettore
[2, "get-var-001", "GetVariables", {
"getVariableData": [
{
"component": { "name": "EVSE", "evse": { "id": 1 } },
"variable": { "name": "AvailabilityState" },
"attributeType": "Actual"
},
{
"component": { "name": "Connector", "evse": { "id": 1, "connectorId": 1 } },
"variable": { "name": "ConnectorType" },
"attributeType": "Actual"
},
{
"component": { "name": "SmartChargingCtrlr" },
"variable": { "name": "ChargingProfileMaxStackLevel" },
"attributeType": "Actual"
}
]
}]
// CALLRESULT dalla Charging Station
[3, "get-var-001", {
"getVariableResult": [
{
"attributeStatus": "Accepted",
"component": { "name": "EVSE", "evse": { "id": 1 } },
"variable": { "name": "AvailabilityState" },
"attributeValue": "Available"
},
{
"attributeStatus": "Accepted",
"component": { "name": "Connector", "evse": { "id": 1, "connectorId": 1 } },
"variable": { "name": "ConnectorType" },
"attributeValue": "cCCS2"
},
{
"attributeStatus": "Accepted",
"component": { "name": "SmartChargingCtrlr" },
"variable": { "name": "ChargingProfileMaxStackLevel" },
"attributeValue": "5"
}
]
}]
Profili di Sicurezza OCPP 2.0.1
OCPP 2.0.1 introduce tre Security Profile progressivi che definiscono il livello di protezione della comunicazione tra Charging Station e CSMS. Il profilo deve essere scelto in fase di deployment e configura automaticamente il meccanismo di autenticazione e cifratura.
| Caratteristica | Profile 1 | Profile 2 | Profile 3 |
|---|---|---|---|
| URL WebSocket | ws:// (no TLS) | wss:// (TLS) | wss:// (TLS) |
| Cifratura | Nessuna | TLS 1.2+ | TLS 1.2+ |
| Auth Stazione | Password (Basic Auth) | Password (Basic Auth) | Certificato X.509 client |
| Auth CSMS | Nessuna | Certificato server TLS | Certificato server TLS |
| Protezione MitM | No | Parziale (solo CSMS auth) | Completa (mutual TLS) |
| Gestione Cert. | Non necessaria | Solo CA root sul device | PKI completa: CA + cert client |
| Uso Consigliato | Solo ambienti di test | Produzione standard | Produzione critica, P&C ISO 15118 |
Gestione Certificati in Produzione (Security Profile 3)
Con il Security Profile 3, la gestione del ciclo di vita dei certificati diventa operazione critica. OCPP 2.0.1 include messaggi dedicati: SignCertificate (la stazione richiede firma di un CSR), CertificateSigned (il CSMS installa il certificato firmato), DeleteCertificate (rimuove un certificato obsoleto), GetInstalledCertificateIds (lista certificati installati). E essenziale una PKI robusta con rinnovo automatico almeno 30 giorni prima della scadenza, monitoraggio continuo della validita e meccanismo di revoca CRL/OCSP.
Smart Charging e Load Management
Lo Smart Charging (blocco K) e la funzionalità più critica per operatori con grandi installazioni. Permette al CSMS di controllare dinamicamente la potenza erogata da ogni EVSE in base a vincoli di rete, tariffe energetiche, priorità utenti e capacità del trasformatore.
Gerarchia dei Profili di Carica
OCPP 2.0.1 definisce quattro tipi di profili di carica con stack levels (priorità):
| Tipo Profilo | Scopo | Applicazione | Override |
|---|---|---|---|
| ChargingStationMaxProfile | Limite massimo assoluto dell'intera stazione | Protezione trasformatore, contratto di fornitura | Non overridable |
| ChargingStationExternalConstraints | Limiti da sistemi esterni (DSO, aggregatori) | Demand response, bilanciamento rete | Solo da profile più alto |
| TxDefaultProfile | Profilo predefinito per le transazioni | Politiche tariffarie, scheduling base, solare | Da TxProfile specifico |
| TxProfile | Profilo specifico per una transazione | Priorità utente, preferenze individuali | Massima secificità |
SetChargingProfile: Peak Shaving e Solar Integration
// Strategia: integra produzione solare + peak shaving ore serali
// Scenario: sito con 50kW fotovoltaico, trasformatore 100A, picco 19-21h
// 1. Limite massimo stazione (rispetta contratto di fornitura)
[2, "smart-max-001", "SetChargingProfile", {
"evseId": 0,
"chargingProfile": {
"id": 1,
"stackLevel": 1,
"chargingProfilePurpose": "ChargingStationMaxProfile",
"chargingProfileKind": "Absolute",
"chargingSchedule": [{
"id": 1,
"chargingRateUnit": "A",
"chargingSchedulePeriod": [
{ "startPeriod": 0, "limit": 100.0 }
]
}]
}
}]
// 2. Profilo solare + peak shaving per un singolo EVSE
[2, "smart-solar-001", "SetChargingProfile", {
"evseId": 1,
"chargingProfile": {
"id": 100,
"stackLevel": 0,
"chargingProfilePurpose": "TxDefaultProfile",
"chargingProfileKind": "Absolute",
"validFrom": "2026-03-09T00:00:00Z",
"validTo": "2026-03-10T00:00:00Z",
"chargingSchedule": [{
"id": 1,
"chargingRateUnit": "A",
"startSchedule": "2026-03-09T06:00:00Z",
"chargingSchedulePeriod": [
{ "startPeriod": 0, "limit": 8.0, "numberPhases": 3 },
{ "startPeriod": 7200, "limit": 32.0, "numberPhases": 3 },
{ "startPeriod": 14400, "limit": 32.0, "numberPhases": 3 },
{ "startPeriod": 43200, "limit": 16.0, "numberPhases": 3 },
{ "startPeriod": 46800, "limit": 8.0, "numberPhases": 3 },
{ "startPeriod": 54000, "limit": 24.0, "numberPhases": 3 }
]
}]
}
}]
// Orario potenza: 06-08h: 8A (offpeak, bassa produzione solare)
// 08-12h: 32A (piena produzione FV, massima potenza)
// 12-18h: 32A (picco solare, alta produzione)
// 18-19h: 16A (calo solare, riduzione)
// 19-21h: 8A (picco domanda residenziale, min potenza)
// 21-24h: 24A (fine picco, potenza media)
Algoritmo di Load Balancing Dinamico
Un algoritmo di load management distribuisce la potenza disponibile tra le sessioni attive in tempo reale, rispettando il limite del trasformatore e le priorità. L'approccio più comune e il Weighted Fair Share con vincoli min/max.
interface ChargingSession {
readonly stationId: string;
readonly evseId: number;
readonly transactionId: string;
readonly priority: number; // 0-9 (9 = massima)
readonly minChargingRate: number; // A minimi per caricare
readonly maxChargingRate: number; // A massimi del connettore
readonly currentChargingRate: number;
readonly energyDelivered: number; // Wh totali erogati
readonly targetEnergy?: number; // Wh target (se specificato dall'utente)
readonly isEV3Phase: boolean; // Veicolo trifase
}
interface LoadBalancerConfig {
readonly maxSitePowerAmps: number; // A max del trasformatore
readonly reservedBuildingAmps: number; // A riservati per l'edificio
readonly minSessionAmps: number; // A minimi per sessione (tipico: 6A)
readonly rebalanceIntervalSec: number; // Secondi tra ricalcoli (tipico: 30s)
}
interface Allocation {
readonly stationId: string;
readonly evseId: number;
readonly allocatedAmps: number;
readonly phases: number;
}
function calculateChargingAllocations(
sessions: ReadonlyArray<ChargingSession>,
config: LoadBalancerConfig
): ReadonlyArray<Allocation> {
if (sessions.length === 0) return [];
const availableAmps = config.maxSitePowerAmps
- config.reservedBuildingAmps;
// Step 1: ordina per priorità (desc), poi energia erogata (asc = meno carico prima)
const sorted = [...sessions].sort((a, b) => {
if (b.priority !== a.priority) return b.priority - a.priority;
return a.energyDelivered - b.energyDelivered;
});
// Step 2: garantisci potenza minima a tutti
const minRequired = sorted.length * config.minSessionAmps;
if (minRequired > availableAmps) {
// Caso critico: potenza insufficiente, sospendi sessioni a bassa priorità
return sorted
.slice(0, Math.floor(availableAmps / config.minSessionAmps))
.map((s) => ({
stationId: s.stationId,
evseId: s.evseId,
allocatedAmps: config.minSessionAmps,
phases: s.isEV3Phase ? 3 : 1,
}));
}
// Step 3: distribuzione proporzionale ai pesi di priorità
const totalWeight = sorted.reduce(
(sum, s) => sum + (1 + s.priority), 0
);
const remainingAmps = availableAmps - minRequired;
const allocations = sorted.map((session) => {
const weight = (1 + session.priority) / totalWeight;
const bonus = remainingAmps * weight;
const raw = config.minSessionAmps + bonus;
// Applica vincoli min/max del connettore
const allocatedAmps = Math.max(
config.minSessionAmps,
Math.min(session.maxChargingRate, Math.round(raw * 10) / 10)
);
return {
stationId: session.stationId,
evseId: session.evseId,
allocatedAmps,
phases: session.isEV3Phase ? 3 : 1,
};
});
// Step 4: verifica finale che il totale non superi il limite
const total = allocations.reduce((s, a) => s + a.allocatedAmps, 0);
if (total <= availableAmps) return allocations;
// Riscaling proporzionale
const scale = availableAmps / total;
return allocations.map((a) => ({
...a,
allocatedAmps: Math.max(
config.minSessionAmps,
Math.round(a.allocatedAmps * scale * 10) / 10
),
}));
}
Implementazione Backend CSMS con Python e PostgreSQL
La libreria Python ocpp di MobilityHouse (open source, 2000+ star su GitHub)
e l'implementazione di riferimento più diffusa per CSMS. Combiniamo la libreria con
asyncio, websockets e asyncpg per PostgreSQL per
costruire un backend production-ready.
Schema PostgreSQL per il CSMS
-- Registro stazioni di ricarica
CREATE TABLE charging_stations (
station_id TEXT PRIMARY KEY,
vendor_name TEXT NOT NULL,
model TEXT NOT NULL,
serial_number TEXT,
firmware_version TEXT,
security_profile SMALLINT NOT NULL DEFAULT 1,
last_boot_reason TEXT,
last_seen_at TIMESTAMPTZ,
is_online BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Stato EVSE e connettori
CREATE TABLE evse_status (
station_id TEXT NOT NULL REFERENCES charging_stations(station_id),
evse_id SMALLINT NOT NULL,
connector_id SMALLINT NOT NULL,
connector_type TEXT, -- cCCS2, cCHAdeMO, cType2, sType3
status TEXT NOT NULL DEFAULT 'Unknown',
error_code TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (station_id, evse_id, connector_id)
);
-- Transazioni di ricarica
CREATE TABLE transactions (
transaction_id TEXT PRIMARY KEY,
station_id TEXT NOT NULL REFERENCES charging_stations(station_id),
evse_id SMALLINT NOT NULL,
connector_id SMALLINT,
id_token TEXT NOT NULL,
id_token_type TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'Started',
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
meter_start_wh NUMERIC(12, 3),
meter_end_wh NUMERIC(12, 3),
energy_wh NUMERIC(12, 3) GENERATED ALWAYS AS (meter_end_wh - meter_start_wh) STORED,
stop_reason TEXT,
total_cost_cents INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Meter values (time series - usare TimescaleDB in produzione)
CREATE TABLE meter_values (
id BIGSERIAL PRIMARY KEY,
transaction_id TEXT REFERENCES transactions(transaction_id),
station_id TEXT NOT NULL,
evse_id SMALLINT NOT NULL,
sampled_at TIMESTAMPTZ NOT NULL,
energy_wh NUMERIC(12, 3),
power_w NUMERIC(10, 2),
current_a NUMERIC(8, 3),
voltage_v NUMERIC(8, 2),
soc_pct SMALLINT -- State of Charge da ISO 15118
);
CREATE INDEX idx_meter_values_station_time
ON meter_values(station_id, sampled_at DESC);
CREATE INDEX idx_transactions_station_id
ON transactions(station_id, started_at DESC);
-- Token di autorizzazione (lista locale cache)
CREATE TABLE authorization_cache (
id_token TEXT NOT NULL,
id_token_type TEXT NOT NULL,
status TEXT NOT NULL, -- Accepted, Invalid, Blocked, Expired
group_id TEXT,
expiry_date TIMESTAMPTZ,
cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id_token, id_token_type)
);
CSMS Backend Python Completo
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional, Any
import asyncpg
import websockets
from ocpp.routing import on
from ocpp.v201 import ChargePoint as Cp
from ocpp.v201 import call, call_result
from ocpp.v201.enums import (
Action, RegistrationStatusType, AuthorizationStatusType,
ConnectorStatusType
)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(name)s %(levelname)s %(message)s'
)
log = logging.getLogger('csms')
# Pool globale connessioni PostgreSQL
_db_pool: Optional[asyncpg.Pool] = None
async def get_db() -> asyncpg.Pool:
global _db_pool
if _db_pool is None:
_db_pool = await asyncpg.create_pool(
dsn='postgresql://csms:password@localhost/csms_db',
min_size=5,
max_size=20,
)
return _db_pool
class ChargePointHandler(Cp):
"""
Handler OCPP 2.0.1 per una singola Charging Station.
Un'istanza per ogni connessione WebSocket attiva.
"""
@on(Action.boot_notification)
async def on_boot_notification(
self, charging_station: dict, reason: str, **kwargs
) -> call_result.BootNotification:
log.info(
f"Boot: {self.id} | "
f"{charging_station['vendor_name']} {charging_station['model']} "
f"| reason={reason}"
)
db = await get_db()
await db.execute(
"""
INSERT INTO charging_stations
(station_id, vendor_name, model, serial_number,
firmware_version, last_boot_reason, last_seen_at, is_online)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), TRUE)
ON CONFLICT (station_id) DO UPDATE SET
vendor_name = EXCLUDED.vendor_name,
model = EXCLUDED.model,
firmware_version = EXCLUDED.firmware_version,
last_boot_reason = EXCLUDED.last_boot_reason,
last_seen_at = NOW(),
is_online = TRUE
""",
self.id,
charging_station['vendor_name'],
charging_station['model'],
charging_station.get('serial_number'),
charging_station.get('firmware_version'),
reason,
)
return call_result.BootNotification(
current_time=datetime.now(timezone.utc).isoformat(),
interval=300,
status=RegistrationStatusType.accepted,
)
@on(Action.heartbeat)
async def on_heartbeat(self) -> call_result.Heartbeat:
db = await get_db()
await db.execute(
"UPDATE charging_stations SET last_seen_at = NOW() WHERE station_id = $1",
self.id
)
return call_result.Heartbeat(
current_time=datetime.now(timezone.utc).isoformat()
)
@on(Action.status_notification)
async def on_status_notification(
self, timestamp: str, connector_status: str,
evse_id: int, connector_id: int, **kwargs
) -> call_result.StatusNotification:
log.info(
f"Status: {self.id} EVSE[{evse_id}]"
f"Connector[{connector_id}] = {connector_status}"
)
db = await get_db()
await db.execute(
"""
INSERT INTO evse_status
(station_id, evse_id, connector_id, status, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (station_id, evse_id, connector_id) DO UPDATE SET
status = EXCLUDED.status,
updated_at = NOW()
""",
self.id, evse_id, connector_id, connector_status
)
return call_result.StatusNotification()
@on(Action.authorize)
async def on_authorize(
self, id_token: dict, **kwargs
) -> call_result.Authorize:
token = id_token['id_token']
token_type = id_token['type']
log.info(f"Authorize: {self.id} token={token} type={token_type}")
db = await get_db()
# Controlla prima la cache locale
row = await db.fetchrow(
"""
SELECT status, group_id, expiry_date
FROM authorization_cache
WHERE id_token = $1 AND id_token_type = $2
AND (expiry_date IS NULL OR expiry_date > NOW())
""",
token, token_type
)
status = AuthorizationStatusType.invalid
if row and row['status'] == 'Accepted':
status = AuthorizationStatusType.accepted
return call_result.Authorize(
id_token_info={'status': status}
)
@on(Action.transaction_event)
async def on_transaction_event(
self,
event_type: str,
timestamp: str,
trigger_reason: str,
seq_no: int,
transaction_info: dict,
evse: Optional[dict] = None,
id_token: Optional[dict] = None,
meter_value: Optional[list] = None,
**kwargs
) -> call_result.TransactionEvent:
tx_id = transaction_info['transaction_id']
log.info(
f"TransactionEvent: {self.id} {event_type} "
f"tx={tx_id} trigger={trigger_reason}"
)
db = await get_db()
if event_type == 'Started':
await self._handle_tx_started(
db, tx_id, evse, id_token, timestamp, meter_value
)
elif event_type == 'Updated' and meter_value:
await self._handle_tx_updated(db, tx_id, evse, meter_value)
elif event_type == 'Ended':
await self._handle_tx_ended(
db, tx_id, timestamp, transaction_info, meter_value
)
return call_result.TransactionEvent(
total_cost=0,
charging_priority=0,
id_token_info={'status': AuthorizationStatusType.accepted},
)
async def _handle_tx_started(
self, db, tx_id, evse, id_token, timestamp, meter_value
):
evse_id = evse['id'] if evse else 0
connector_id = evse.get('connector_id') if evse else None
token = id_token['id_token'] if id_token else 'unknown'
token_type = id_token['type'] if id_token else 'Local'
meter_start = self._extract_energy(meter_value)
await db.execute(
"""
INSERT INTO transactions
(transaction_id, station_id, evse_id, connector_id,
id_token, id_token_type, state, started_at, meter_start_wh)
VALUES ($1, $2, $3, $4, $5, $6, 'Started', $7, $8)
ON CONFLICT (transaction_id) DO NOTHING
""",
tx_id, self.id, evse_id, connector_id,
token, token_type, timestamp, meter_start
)
async def _handle_tx_updated(self, db, tx_id, evse, meter_value):
evse_id = evse['id'] if evse else 0
energy = self._extract_energy(meter_value)
power = self._extract_power(meter_value)
if energy is not None:
await db.execute(
"""
INSERT INTO meter_values
(transaction_id, station_id, evse_id, sampled_at, energy_wh, power_w)
VALUES ($1, $2, $3, NOW(), $4, $5)
""",
tx_id, self.id, evse_id, energy, power
)
async def _handle_tx_ended(
self, db, tx_id, timestamp, transaction_info, meter_value
):
meter_end = self._extract_energy(meter_value)
stop_reason = transaction_info.get('stopped_reason')
await db.execute(
"""
UPDATE transactions SET
state = 'Ended',
ended_at = $1,
meter_end_wh = $2,
stop_reason = $3
WHERE transaction_id = $4
""",
timestamp, meter_end, stop_reason, tx_id
)
def _extract_energy(self, meter_values: Optional[list]) -> Optional[float]:
if not meter_values:
return None
for mv in meter_values:
for sv in mv.get('sampled_value', []):
if sv.get('measurand', '') == 'Energy.Active.Import.Register':
return float(sv['value'])
return None
def _extract_power(self, meter_values: Optional[list]) -> Optional[float]:
if not meter_values:
return None
for mv in meter_values:
for sv in mv.get('sampled_value', []):
if sv.get('measurand', '') == 'Power.Active.Import':
return float(sv['value'])
return None
# === Comandi CSMS -> Stazione ===
async def send_remote_start(
self, evse_id: int, id_token: str, limit_amps: float = 32.0
) -> str:
"""Avvia una sessione da remoto su EVSE specificato."""
request = call.RequestStartTransaction(
id_token={'id_token': id_token, 'type': 'Central'},
evse_id=evse_id,
charging_profile={
'id': 999,
'stack_level': 0,
'charging_profile_purpose': 'TxProfile',
'charging_profile_kind': 'Relative',
'charging_schedule': [{
'id': 1,
'charging_rate_unit': 'A',
'charging_schedule_period': [
{'start_period': 0, 'limit': limit_amps}
],
}],
},
)
response = await self.call(request)
log.info(f"RemoteStart {self.id} EVSE{evse_id}: {response.status}")
return response.status
async def send_charging_profile(
self, evse_id: int, profile: dict
) -> str:
"""Imposta un profilo di carica per smart charging."""
request = call.SetChargingProfile(
evse_id=evse_id,
charging_profile=profile
)
response = await self.call(request)
log.info(f"ChargingProfile {self.id} EVSE{evse_id}: {response.status}")
return response.status
# Registry globale delle connessioni attive
_connected_stations: dict[str, ChargePointHandler] = {}
async def on_connect(websocket, path: str):
"""Callback per nuove connessioni WebSocket OCPP."""
station_id = path.strip('/').split('/')[-1]
if not station_id:
await websocket.close(1008, 'Missing station ID')
return
log.info(f"Connessione da: {station_id} | path={path}")
cp = ChargePointHandler(station_id, websocket)
_connected_stations[station_id] = cp
try:
await cp.start()
except websockets.exceptions.ConnectionClosed as e:
log.info(f"Disconnesso: {station_id} code={e.code}")
except Exception as e:
log.error(f"Errore: {station_id} - {e}")
finally:
_connected_stations.pop(station_id, None)
db = await get_db()
await db.execute(
"UPDATE charging_stations SET is_online = FALSE WHERE station_id = $1",
station_id
)
async def main():
await get_db() # Inizializza pool DB
log.info("CSMS OCPP 2.0.1 avviato")
server = await websockets.serve(
on_connect,
'0.0.0.0',
9000,
subprotocols=['ocpp2.0.1'],
# TLS: aggiungere ssl=ssl_context per Security Profile 2-3
ping_interval=60,
ping_timeout=30,
max_size=1_048_576, # 1MB max message size
)
log.info("In ascolto su ws://0.0.0.0:9000/ocpp/{stationId}")
await server.wait_closed()
if __name__ == '__main__':
asyncio.run(main())
ISO 15118 e Plug & Charge
ISO 15118 definisce la comunicazione ad alto livello tra veicolo elettrico (EV) e stazione di ricarica (EVSE) attraverso Power Line Communication (PLC) sul cavo di ricarica DC (CCS). OCPP 2.0.1 integra nativamente ISO 15118 attraverso il blocco funzionale M, abilitando il Plug & Charge: il veicolo si autentica automaticamente tramite certificato digitale X.509, senza RFID o app mobile.
Architettura PKI V2G (Vehicle-to-Grid)
Il sistema di certificati per Plug & Charge si basa su una PKI (Public Key Infrastructure) gerarchica specifica per la mobilita elettrica:
V2G Root CA (Root of Trust - gestita da OEM o eMSP)
|
+-- V2G Intermediate CA
| |
| +-- EVSE Certificate (installato nella stazione)
| CN = EVSE-IT-MIL-001
|
+-- eMobility Service Provider CA (eMSP)
|
+-- Contract Certificate (installato nel veicolo)
CN = IT.CPO.000001234 (eMAID - e-Mobility Account Identifier)
SubjectAltName = eMAID:IT.CPO.000001234
Flusso Plug & Charge Completo
EV EVSE CSMS eMSP
| | | |
|-- Plug cavo DC --> | | |
| | | |
|<= ISO 15118-2 TLS =>| (PLC sul cavo SLAC) | |
| | | |
|-- ContractCert --->| | |
| (eMAID, X.509) | | |
| |--- Authorize req ----->| |
| | idToken.type=eMAID | |
| | |--- OCPI check --->|
| | |<-- Contract OK --|
| | | |
| |<-- Authorize.conf ----| |
| | status: Accepted | |
| | | |
|<= Charging Start ==>| | |
| | | |
| EV invia target | | |
|-- EnergyRequest --->| | |
| SoC: 45% | | |
| Target: 80% | | |
| Departure: 18:30 | | |
| |--- TransactionEvent -->| |
| | ISO15118Trigger | |
| | | |
| |<-- SetChargingProfile--| |
|<= Schedule via PLC =>| | |
Stato ISO 15118 in Produzione (2026)
- ISO 15118-2: Plug & Charge AC/DC - ampiamente supportato dai caricatori DC HPC (Ionity, Fastned, Tesla Supercharger V3)
- ISO 15118-20: Supporto V2G bidirezionale - supporto hardware pronto, software in rollout 2025-2026
- AFIR Requisito: tutte le nuove stazioni V2G-capable devono supportare ISO 15118 dal 2026
- Compliance AFIR 2027: ogni caricatore installato dopo 01/01/2027 deve essere smart-charging ready
- V2G reale in Italia: primi pilot con Enel X Way e Nissan Leaf su standard V2H (Vehicle-to-Home)
Architettura Scalabile: da 10 a 100.000 Charge Points
Un CSMS enterprise deve gestire da poche decine a centinaia di migliaia di connessioni WebSocket concorrenti. L'architettura evolve in fasi, con componenti aggiuntivi che entrano in gioco a diverse scale.
Fase 1: Small Scale (10-500 stazioni)
+------------------+ WebSocket/OCPP +------------------+
| Charging |------------------------| CSMS Monolitico |
| Stations (10-500)| wss://csms:9000/ocpp | Python/asyncio |
+------------------+ | Port 9000 |
+--------+---------+
|
+-------+-------+
| PostgreSQL |
| Redis (cache) |
+---------------+
Stack: Python asyncio + PostgreSQL + Redis
Deployment: 1 VM (4 vCPU, 8GB RAM), 1 DB managed
Costo: ~$200/mese
Fase 2: Medium Scale (500-10.000 stazioni)
+------------+ +------------------+ +--------------+
| Load | | WS Gateway #1 | | Message |
| Balancer +---->| (asyncio CSMS) +---->| Broker |
| (HAProxy) | | Max 2000 conn | | (RabbitMQ) |
| | +------------------+ | |
| Sticky +---->| WS Gateway #2 +---->| |
| Sessions | | (asyncio CSMS) | +--------------+
| | +------------------+ |
+------------+ +------+------+
| Business |
| Services |
| (FastAPI) |
+------+------+
|
+----------+----------+
| PostgreSQL (HA) |
| TimescaleDB |
| Redis Cluster |
+---------------------+
Sticky sessions: basate su station_id nel path URL
Cross-node ops: Redis pub/sub per inviare comandi alle stazioni
Costo: ~$2.000/mese (K8s managed)
Fase 3: Large Scale (10.000-100.000 stazioni)
Global Load Balancer (Anycast)
|
+---------------+---------------+
| |
Region EU-WEST Region EU-SOUTH
+------------------+ +------------------+
| WS Gateway Pool | | WS Gateway Pool |
| (50 pods, 2000 | | (30 pods) |
| conn each = 100K)| +--------+---------+
+--------+---------+ |
| |
+-------------+----------------+
|
+-------+--------+
| Apache Kafka |
| (12 partitions)|
| per topic |
+-------+--------+
|
+-----------------+------------------+
| | |
+------+------+ +-------+------+ +--------+------+
| Transaction | | Smart | | Device |
| Service | | Charging Svc | | Mgmt Svc |
| (10 replicas)| | (5 replicas) | | (3 replicas) |
+------+------+ +-------+------+ +--------+------+
| | |
+--------+--------+------------------+
|
+--------+--------+
| PostgreSQL |
| Citus (sharding) |
| Shard key: |
| station_id hash |
+--------+---------+
|
+--------+--------+
| TimescaleDB |
| (meter values) |
+--------+--------+
Kafka Topics:
- ocpp.boot-notification (chiave: station_id)
- ocpp.transaction-events (chiave: transaction_id)
- ocpp.meter-values (chiave: station_id)
- ocpp.status-notifications (chiave: station_id)
- csms.commands (chiave: station_id)
Throughput target: 1M messaggi/ora, latenza P99 < 200ms
Costo: ~$30.000/mese (multi-region Kubernetes)
Gestione Connessioni Cross-Node con Redis
In un deployment multi-nodo, il CSMS deve sapere su quale gateway si trova ogni stazione per inviare comandi (SetChargingProfile, RemoteStart, ecc.). Redis pub/sub risolve il problema:
import json
import asyncio
import redis.asyncio as aioredis
redis_client = aioredis.from_url(
'redis://redis-cluster:6379',
encoding='utf-8',
decode_responses=True
)
# Registra il nodo della connessione
async def register_connection(station_id: str, gateway_id: str):
await redis_client.setex(
f"csms:gateway:{station_id}",
value=gateway_id,
time=600 # TTL: 10 minuti, rinnovato a ogni heartbeat
)
# Pubblica un comando verso una stazione (qualunque nodo sia)
async def publish_command(station_id: str, action: str, payload: dict):
channel = f"csms:commands:{station_id}"
await redis_client.publish(channel, json.dumps({
'action': action,
'payload': payload
}))
# Su ogni nodo gateway: ascolta i comandi per le stazioni connesse
async def listen_for_commands(connected_stations: dict):
pubsub = redis_client.pubsub()
# Sottoscrivi ai canali delle stazioni connesse a questo nodo
async def subscribe_station(station_id: str):
await pubsub.subscribe(f"csms:commands:{station_id}")
async for message in pubsub.listen():
if message['type'] != 'message':
continue
station_id = message['channel'].split(':')[-1]
cp = connected_stations.get(station_id)
if not cp:
continue # Stazione non su questo nodo, ignora
cmd = json.loads(message['data'])
try:
if cmd['action'] == 'SetChargingProfile':
await cp.send_charging_profile(
cmd['payload']['evse_id'],
cmd['payload']['profile']
)
elif cmd['action'] == 'RemoteStart':
await cp.send_remote_start(
cmd['payload']['evse_id'],
cmd['payload']['id_token'],
)
except Exception as e:
log.error(f"Errore esecuzione comando {cmd['action']}: {e}")
Monitoring, Metriche e Grafana Dashboard
Un CSMS in produzione richiede un sistema di osservabilità completo. Le metriche chiave da monitorare riguardano la salute dell'infrastruttura, la qualità del servizio e le performance operative.
Metriche Operative Chiave
| Metrica | Formula/Fonte | Target SLA | Alert Threshold |
|---|---|---|---|
| Station Availability | Stazioni online / Totale stazioni x 100 | >= 99% | < 95% |
| OCPP Message Latency P99 | Tempo CALL -> CALLRESULT (95th percentile) | < 2s | > 5s |
| Transaction Success Rate | Tx completate / Tx avviate x 100 | >= 98% | < 95% |
| Energy Delivered (kWh/ora) | Sum MeterValues per ora | Baseline + 10% | < baseline -20% |
| Auth Rejection Rate | Auth Invalid / Auth totali x 100 | < 2% | > 10% (possibile attacco) |
| WebSocket Reconnections/ora | Counter nuove connessioni per stazione | < 2/ora/stazione | > 10/ora/stazione |
| Smart Charging Compliance | Potenza effettiva vs profilo impostato | +/- 5% | Deviazione > 15% |
| Certificate Expiry Days | Giorni alla scadenza certificati TLS | > 30 giorni | < 30 giorni (alert rinnovo) |
Prometheus Exporter per CSMS
from prometheus_client import (
Counter, Gauge, Histogram, start_http_server
)
# Metriche Prometheus
OCPP_MESSAGES_TOTAL = Counter(
'ocpp_messages_total',
'Numero totale messaggi OCPP processati',
['action', 'direction', 'status'] # direction: inbound/outbound
)
OCPP_MESSAGE_DURATION = Histogram(
'ocpp_message_duration_seconds',
'Latenza elaborazione messaggi OCPP',
['action'],
buckets=[0.01, 0.05, 0.1, 0.5, 1.0, 2.0, 5.0, 10.0]
)
STATIONS_CONNECTED = Gauge(
'csms_stations_connected_total',
'Numero stazioni connesse al CSMS'
)
ACTIVE_TRANSACTIONS = Gauge(
'csms_active_transactions_total',
'Numero transazioni di ricarica attive'
)
ENERGY_DELIVERED_WH = Counter(
'csms_energy_delivered_wh_total',
'Energia totale erogata in Wh',
['station_id']
)
AUTH_RESULTS = Counter(
'csms_authorization_results_total',
'Risultati delle autorizzazioni OCPP',
['status'] # Accepted, Invalid, Blocked, Expired
)
SMART_CHARGING_EVENTS = Counter(
'csms_smart_charging_events_total',
'Operazioni smart charging',
['action', 'result']
)
def start_metrics_server(port: int = 8001):
"""Avvia il server HTTP Prometheus su porta specificata."""
start_http_server(port)
log.info(f"Prometheus metrics su http://0.0.0.0:{port}/metrics")
# Decorator per misurare latenza handler
import time
import functools
def track_ocpp_handler(action: str):
def decorator(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
start = time.monotonic()
try:
result = await func(*args, **kwargs)
OCPP_MESSAGES_TOTAL.labels(
action=action, direction='inbound', status='success'
).inc()
return result
except Exception as e:
OCPP_MESSAGES_TOTAL.labels(
action=action, direction='inbound', status='error'
).inc()
raise
finally:
OCPP_MESSAGE_DURATION.labels(action=action).observe(
time.monotonic() - start
)
return wrapper
return decorator
// Pannelli principali per dashboard Grafana CSMS
// 1. Stazioni online (Gauge)
{
"title": "Stazioni Connesse",
"type": "gauge",
"fieldConfig": {
"defaults": {
"thresholds": {
"steps": [
{ "color": "red", "value": 0 },
{ "color": "yellow", "value": 90 },
{ "color": "green", "value": 99 }
]
}
}
},
"targets": [{
"expr": "csms_stations_connected_total / csms_stations_registered_total * 100",
"legendFormat": "Availability %"
}]
}
// 2. Latenza messaggi OCPP P99 (Time Series)
{
"title": "OCPP Message Latency P99",
"type": "timeseries",
"targets": [{
"expr": "histogram_quantile(0.99, rate(ocpp_message_duration_seconds_bucket[5m]))",
"legendFormat": "P99 - {{action}}"
}]
}
// 3. Energia erogata (Stat panel)
{
"title": "Energia Totale Erogata oggi (kWh)",
"type": "stat",
"targets": [{
"expr": "increase(csms_energy_delivered_wh_total[24h]) / 1000",
"legendFormat": "kWh"
}]
}
// 4. Auth rejection rate - alert su attacchi
{
"title": "Rejection Rate Autorizzazioni (%)",
"type": "timeseries",
"targets": [{
"expr": "rate(csms_authorization_results_total{status='Invalid'}[5m]) / rate(csms_authorization_results_total[5m]) * 100"
}]
}
Normativa Italiana ed Europea: AFIR, PNIRE, PNRR
L'infrastruttura di ricarica EV in Italia e Europa e fortemente regolamentata. La conformità normativa non e opzionale: riguarda sia i requisiti tecnici delle stazioni (metrologia legale, accessibilità, pagamento) sia gli standard di comunicazione.
AFIR (Alternative Fuels Infrastructure Regulation - EU 2023/1804)
L'AFIR e entrata in vigore ad aprile 2024 e definisce un calendario preciso di obblighi per l'infrastruttura di ricarica sulle reti TEN-T e nelle aree urbane:
| Scadenza | Requisito | Applicabilita |
|---|---|---|
| 31 dic 2025 | Stazione >= 150 kW ogni 60 km sulla rete TEN-T Core | Tutta l'UE |
| 31 dic 2027 | Stazione >= 150 kW ogni 60 km rete TEN-T Comprehensive | Tutta l'UE |
| 14 apr 2025 | Dati statici e dinamici gratuiti (posizione, tipo connettore, disponibilità) | Stazioni pubbliche |
| 01 gen 2027 | Smart charging ready per stazioni nuove/ristrutturate > 22 kW | Stazioni pubbliche |
| Immediato | Pagamento ad-hoc senza abbonamento (carta bancaria contactless) | Stazioni > 50 kW pubbliche |
| 2026+ | ISO 15118 per stazioni V2G-capable | Stazioni bidirezionali |
PNIRE e PNRR per l'Italia
In Italia, l'implementazione di AFIR avviene attraverso due strumenti principali:
- PNIRE (Piano Nazionale Infrastrutture Ricarica Elettrica): gestito dal MASE (Ministero Ambiente e Sicurezza Energetica), definisce gli obiettivi nazionali: 13.755 stazioni pubbliche nel 2025, focus su rete stradale e aree urbane
- PNRR Missione 2, Investimento 4.3: oltre 700 milioni di euro allocati per High Power Charging (HPC >= 150 kW) su autostrade e aree di servizio. Come da MEMORY, il PNRR ha allocato 12.7 miliardi EUR totali, con utilizzo ancora parziale
- Bando MASE 2024-2025: incentivi per operatori privati che installano infrastrutture in aree a bassa densita di ricarica (Sud Italia, aree rurali). Sussidio fino al 60% dei costi di installazione
Situazione Italiana: 73.000 Punti di Ricarica nel 2025
Al 31 dicembre 2025, l'Italia conta 73.000+ punti di ricarica pubblici (+18% rispetto al 2024), con 93% di copertura nazionale territoriale. Di questi: circa 12.000 sono punti di ricarica rapida (> 22 kW) e 5.000 sono HPC (>= 150 kW). La regione con più infrastruttura e la Lombardia (23%), seguita da Lazio (12%) e Toscana (9%). Il Sud resta un'area prioritaria per i finanziamenti PNRR, con obiettivo di colmare il divario entro 2026.
Obblighi Tecnici per il Software CSMS
- OCPI (Open Charge Point Interface): protocollo obbligatorio per il roaming tra CPO (Charge Point Operator) e eMSP (e-Mobility Service Provider). Versione OCPI 2.2.1 raccomandata
- Metrologia Legale: in Germania (Eichrecht) e progressivamente in EU (MID - Measurement Instruments Directive), i sistemi di misura devono essere certificati e le letture devono essere trasparenti e non alterabili dall'utente
- GDPR: i dati delle sessioni di ricarica (RFID, localizzazione, tempi) sono dati personali. Richiede privacy policy, data minimization, e diritto all'oblio
- CDR (Charge Detail Records): devono rispettare il formato OCPI per la fatturazione interoperabile e conservati per almeno 5 anni (obblighi fiscali italiani)
Case Study: Rete di Ricarica Italiana con 50+ Stazioni
Esaminiamo l'architettura di un sistema CSMS reale per un operatore italiano che gestisce 50 stazioni di ricarica DC (22-150 kW) distribuite in parcheggi urbani e centri commerciali in 3 regioni.
Requisiti Operativi
- 50 stazioni, 150 EVSE totali (media 3 EVSE per stazione), 300 connettori (CCS2 + Type2)
- Picco giornaliero: 400-600 sessioni di ricarica (ore 7-9, 12-14, 17-21)
- Smart charging: rispetto limite di 200A per stazione, integrazione con SEM (Site Energy Manager)
- Multi-tenant: 3 CPO con visibilità parziale delle stazioni, roaming OCPI con Enel X, BeCharge
- Uptime SLA: 99.5% mensile per stazione, 99.9% per il CSMS backend
Architettura Scelta
+------------------+ +------------------+ +------------------+
| 50 Stazioni | | HAProxy | | CSMS Primary |
| OCPP 2.0.1 |---->| (WS sticky sess.)|---->| Python asyncio |
| Security Profile 2| | Port 443 (TLS) | | 2 replicas |
+------------------+ +------------------+ +--------+---------+
|
+------------------+ +--------+---------+
| CSMS Worker |<----| Redis Cluster |
| (FastAPI REST) | | (stato sessioni) |
| Dashboard, API | +------------------+
+------------------+
|
+-----------+----------+
| |
+-------+-------+ +---------+------+
| PostgreSQL 16 | | TimescaleDB |
| (transazioni, | | (meter values)|
| auth, device | | 2TB/anno est. |
| model, CDR) | +---------------+
+---------------+
Monitoring: Prometheus + Grafana Cloud
Alerting: PagerDuty (P1: stazione offline >5min, P2: CSMS latency >2s)
CDR/Billing: integrazione ERP via webhook PostgreSQL NOTIFY
Metriche Operative Reali (Mese Tipo)
| Metrica | Valore | Note |
|---|---|---|
| Sessioni/mese | ~14.000 | Media 280 sessioni/giorno |
| Energia erogata | ~85.000 kWh/mese | Media 6 kWh/sessione |
| Disponibilità media stazioni | 99.3% | 0.7% downtime = ~5h/mese per stazione |
| Auth Acceptance Rate | 96.8% | 3.2% rejected = scaduti o non registrati |
| OCPP Latency P95 | 180ms | Include Round-Trip LTE stazione |
| Smart Charging Events/giorno | ~1.200 | 24 ribilanciamenti/ora in media |
| Peak Shaving Efficacy | 92% | 92% delle volte potenza <= limite impostato |
Sicurezza del CSMS: OWASP e Threat Model
Il CSMS e un'infrastruttura critica: una compromissione può causare interruzione della ricarica, manipolazione della fatturazione o attacchi alla rete elettrica tramite i carichi di ricarica. Le minacce principali sono identificabili tramite un threat model specifico.
Threat Model CSMS
| Minaccia | Vettore | Impatto | Mitigazione |
|---|---|---|---|
| Stazione non autorizzata | Connessione con credenziali rubate | Iniezione dati falsi, consumo fraudolento | Security Profile 3 (mTLS), certificate pinning |
| Man-in-the-Middle | Intercettazione WS su reti non sicure | Intercettazione token RFID, manipolazione comandi | TLS 1.3 obbligatorio, certificate transparency |
| Replay Attack | Ritrasmissione di messaggi OCPP catturati | Doppia fatturazione, autorizzazioni non valide | MessageId univoci, timestamp validation, nonce |
| DDoS WebSocket | Flood di connessioni o messaggi | CSMS irraggiungibile, DoS dell'infrastruttura | Rate limiting, connection throttling, WAF |
| SQL Injection via payload OCPP | Payload OCPP con payload SQL nel campo idToken | Esfiltrazione DB, privilege escalation | Prepared statements, ORM, input validation |
| RFID Cloning | Clonazione di tessere RFID legittimi | Sessioni a carico di altri utenti | ISO 15118 P&C, whitelist RFID, anomaly detection |
| Firmware Malicious | Aggiornamento firmware con malware | Controllo fisico della stazione, grid manipulation | Firma digitale firmware, secure boot, SBOM |
Hardening del CSMS: Checklist di Sicurezza
import ssl
import re
from functools import wraps
# 1. Configurazione TLS sicura (Security Profile 2-3)
def create_tls_context(
certfile: str,
keyfile: str,
cafile: str,
require_client_cert: bool = False
) -> ssl.SSLContext:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.load_cert_chain(certfile=certfile, keyfile=keyfile)
if require_client_cert: # Security Profile 3 (mTLS)
ctx.verify_mode = ssl.CERT_REQUIRED
ctx.load_verify_locations(cafile=cafile)
# Disabilita cipher suite deboli
ctx.set_ciphers(
'ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:!aNULL:!eNULL:!LOW:!EXPORT'
)
return ctx
# 2. Rate limiting per connessioni WebSocket
from collections import defaultdict
import time
_connection_attempts: dict[str, list[float]] = defaultdict(list)
MAX_CONN_PER_MINUTE = 10
def check_rate_limit(client_ip: str) -> bool:
"""Ritorna True se il client può connettersi, False se throttled."""
now = time.monotonic()
window = _connection_attempts[client_ip]
# Rimuovi tentativi più vecchi di 60 secondi
_connection_attempts[client_ip] = [
t for t in window if now - t < 60
]
if len(_connection_attempts[client_ip]) >= MAX_CONN_PER_MINUTE:
log.warning(f"Rate limit superato per IP: {client_ip}")
return False
_connection_attempts[client_ip].append(now)
return True
# 3. Validazione MessageId per prevenire replay attack
_seen_message_ids: set[str] = set()
_message_id_pattern = re.compile(r'^[a-zA-Z0-9\-_\.]{1,36}$')
def validate_message_id(msg_id: str) -> bool:
if not _message_id_pattern.match(msg_id):
log.warning(f"MessageId formato invalido: {msg_id}")
return False
if msg_id in _seen_message_ids:
log.warning(f"Possibile replay attack: MessageId duplicato {msg_id}")
return False
_seen_message_ids.add(msg_id)
# In produzione: usa Redis con TTL per pulizia automatica
return True
# 4. Validazione e sanitizzazione payload OCPP
def validate_station_id(station_id: str) -> bool:
"""Valida che station_id sia sicuro (prevenire path traversal)."""
pattern = re.compile(r'^[a-zA-Z0-9\-_\.]{1,50}$')
return bool(pattern.match(station_id))
def validate_id_token(id_token: str) -> bool:
"""Valida formato token RFID/app (max 255 chars, alfanumerico)."""
if not id_token or len(id_token) > 255:
return False
return bool(re.match(r'^[a-zA-Z0-9\-_\.\:]{1,255}$', id_token))
# 5. Audit log di sicurezza (OCPP Security Block A)
async def log_security_event(
station_id: str,
event_type: str,
severity: str,
details: str
):
db = await get_db()
await db.execute(
"""
INSERT INTO security_events
(station_id, event_type, severity, details, logged_at)
VALUES ($1, $2, $3, $4, NOW())
""",
station_id, event_type, severity, details
)
if severity == 'Critical':
log.critical(f"SECURITY: {station_id} | {event_type} | {details}")
Testing e Certificazione OCPP
L'Open Charge Alliance offre un programma di certificazione OCPP 2.0.1 che valida la conformità delle implementazioni. Il processo prevede test automatizzati contro un tool ufficiale (OCTT - Open Charge Test Tool) che verifica ogni use case dei blocchi funzionali dichiarati.
Strategia di Testing per un CSMS
- Unit Test: validazione JSON schema OCPP, logica state machine transazioni, algoritmi load management, funzioni crittografiche
- Integration Test: connessione WebSocket end-to-end con simulatori di stazioni (ocpp-rpc, libocpp), flussi completi boot -> auth -> charge -> stop
- Simulatori:
mobilityhouse/ocppcome client simulato,citrineos(TypeScript),everest(Linux Foundation, C++) per test completi - Load Test: 1000+ connessioni WebSocket concorrenti con k6 o Artillery, misurando latenza P99 e memory footprint del gateway
- Chaos Engineering: disconnessioni casuali, messaggi malformati, timeout, perdita di rete LTE simulata per verificare resilienza e reconnection logic
- Security Test: fuzzing dei payload OCPP, test TLS (testssl.sh), penetration test del WebSocket endpoint
Roadmap: OCPP 2.0.1 verso 2.1
OCPP 2.1, rilasciato nel gennaio 2025, e retrocompatibile con 2.0.1. Per chi implementa oggi, la raccomandazione e:
| Quando | Azione | Beneficio |
|---|---|---|
| Oggi (2026) | Implementa OCPP 2.0.1 come baseline solida | Standard attuale, massimo supporto hardware |
| 2026 Q3-Q4 | Aggiungi supporto OCPP 2.1 per nuovi connettori hardware | V2G, tariffazione nativa, ISO 15118-20 |
| 2027 | Migrazione stazioni legacy 1.6 a 2.0.1 via firmware update | Compliance AFIR smart charging, security upgrade |
| 2028+ | Deploy V2G/V2H su flotte aziendali e abitazioni | Virtual power plant, demand response, revenue da grid services |
Librerie Open Source Consigliate
- Python:
mobilityhouse/ocpp(OCPP 2.0.1 + 2.1, asyncio, 2.5K star) - TypeScript/Node.js:
citrineos(ChargePoint Inc., OCPP 2.0.1, enterprise-grade) - C++:
EVerest/libocpp(Linux Foundation, OCPP 1.6 + 2.0.1, hardware embedded) - Java:
steve(OCPP 1.2-1.6, storico, buon punto di partenza) - Full Stack:
EVerest(Linux Foundation, stack completo EVSE + CSMS, reference implementation) - Testing: OCTT (OCA official),
ocpp-rpcNode.js per simulare stazioni
Conclusioni
OCPP 2.0.1 rappresenta un salto di maturita fondamentale per l'industria della ricarica EV. La struttura modulare a 16 blocchi funzionali, il Device Model gerarchico flessibile, i tre profili di sicurezza progressivi e lo smart charging nativo forniscono gli strumenti necessari per costruire infrastrutture di ricarica scalabili, sicure e interoperabili.
Le sfide nell'implementazione di un CSMS production-ready sono significative: dalla gestione di migliaia di connessioni WebSocket persistenti con sticky sessions, alla state machine delle transazioni, agli algoritmi di load management in tempo reale, alla PKI per Plug & Charge ISO 15118. Tuttavia, l'ecosistema open source e maturo: librerie ben mantenute in Python, TypeScript e C++ coprono la maggior parte della complessità protocollare, permettendo di concentrarsi sulla business logic.
Dal punto di vista normativo, il 2026-2027 e un periodo critico per l'Europa: le scadenze AFIR impongono investimenti infrastrutturali, mentre il PNRR italiano mette a disposizione centinaia di milioni per chi costruisce infrastruttura HPC. Per i software engineer e gli architect di sistemi EnergyTech, padroneggiare OCPP e le sue integrazioni (OCPI per il roaming, OpenADR per il demand response, ISO 15118 per V2G) diventa competenza strategica nel mercato del lavoro.
Nel prossimo articolo della serie EnergyTech esploreremo i protocolli Smart Grid: OpenADR 2.0 per il demand response, IEEE 2030.5 (SEP 2.0) per la comunicazione con dispositivi energetici, e come integrare un CSMS OCPP in un ecosistema di Virtual Power Plant, completando il quadro dell'infrastruttura energetica distribuita.

Commenti
Caricamento commenti...
Accedi per commentare