Salta al contenuto principale

Protocollo OCPP 2.x: Costruire Sistemi di Ricarica EV

Guida completa al protocollo OCPP 2.x per costruire sistemi di ricarica EV: messaging, profili e implementazione backend.

Cosa ne pensi di questo articolo?

Protocollo OCPP 2.x: Costruire Sistemi di Ricarica EV
EnergyTechFoto:Ruben BoekelooPexels

In Breve

Guida completa al protocollo OCPP 2.x per costruire sistemi di ricarica EV: messaging, profili e implementazione backend.

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).

Flusso di connessione WebSocket OCPP 2.0.1

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 e headers di connessione OCPP

# 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 CALL - Richiesta OCPP
// 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 CALLRESULT - Risposta positiva
// 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 CALLERROR - Risposta di errore
// 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.

Gerarchia del Device Model OCPP 2.0.1

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
GetVariables - Lettura configurazione Device Model
// 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

Esempio SetChargingProfile con solar integration e peak shaving
// 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.

load-manager.ts - Algoritmo weighted fair share con priorità
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

csms_schema.sql - Schema database 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

csms_server.py - CSMS OCPP 2.0.1 con asyncio e asyncpg
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)

Architettura enterprise con Kafka e sharding

                    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:

csms_gateway.py - Comandi cross-node via Redis
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

csms_metrics.py - 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
grafana-dashboard.json (snippet) - Dashboard CSMS chiave
// 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

security-config.py - Configurazione sicurezza CSMS
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/ocpp come 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-rpc Node.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

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti