ocpp-rails 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +461 -0
- data/Rakefile +8 -0
- data/app/assets/stylesheets/ocpp/rails/application.css +15 -0
- data/app/channels/ocpp/rails/charge_point_channel.rb +94 -0
- data/app/controllers/ocpp/rails/application_controller.rb +7 -0
- data/app/helpers/ocpp/rails/application_helper.rb +6 -0
- data/app/jobs/ocpp/rails/application_job.rb +6 -0
- data/app/jobs/ocpp/rails/async_hook_job.rb +25 -0
- data/app/jobs/ocpp/rails/authorization_async_hook_job.rb +25 -0
- data/app/jobs/ocpp/rails/change_availability_job.rb +42 -0
- data/app/jobs/ocpp/rails/change_configuration_job.rb +41 -0
- data/app/jobs/ocpp/rails/cleanup_authorizations_job.rb +28 -0
- data/app/jobs/ocpp/rails/cleanup_state_changes_job.rb +28 -0
- data/app/jobs/ocpp/rails/clear_cache_job.rb +37 -0
- data/app/jobs/ocpp/rails/get_configuration_job.rb +38 -0
- data/app/jobs/ocpp/rails/remote_start_transaction_job.rb +39 -0
- data/app/jobs/ocpp/rails/remote_stop_transaction_job.rb +38 -0
- data/app/jobs/ocpp/rails/reset_job.rb +39 -0
- data/app/jobs/ocpp/rails/unlock_connector_job.rb +38 -0
- data/app/mailers/ocpp/rails/application_mailer.rb +8 -0
- data/app/models/ocpp/rails/application_record.rb +7 -0
- data/app/models/ocpp/rails/authorization.rb +26 -0
- data/app/models/ocpp/rails/charge_point.rb +69 -0
- data/app/models/ocpp/rails/charging_session.rb +72 -0
- data/app/models/ocpp/rails/message.rb +17 -0
- data/app/models/ocpp/rails/meter_value.rb +24 -0
- data/app/models/ocpp/rails/state_change.rb +26 -0
- data/app/services/ocpp/rails/actions/authorize_handler.rb +47 -0
- data/app/services/ocpp/rails/actions/boot_notification_handler.rb +62 -0
- data/app/services/ocpp/rails/actions/heartbeat_handler.rb +25 -0
- data/app/services/ocpp/rails/actions/meter_values_handler.rb +97 -0
- data/app/services/ocpp/rails/actions/start_transaction_handler.rb +126 -0
- data/app/services/ocpp/rails/actions/status_notification_handler.rb +99 -0
- data/app/services/ocpp/rails/actions/stop_transaction_handler.rb +121 -0
- data/app/services/ocpp/rails/authorization_hook_manager.rb +102 -0
- data/app/services/ocpp/rails/message_handler.rb +153 -0
- data/app/services/ocpp/rails/meter_anomaly_detector.rb +50 -0
- data/app/services/ocpp/rails/protocol.rb +65 -0
- data/app/services/ocpp/rails/rate_limiter.rb +53 -0
- data/app/services/ocpp/rails/state_change_hook_manager.rb +26 -0
- data/app/services/ocpp/rails/station_authenticator.rb +46 -0
- data/app/services/ocpp/rails/timestamp_parser.rb +25 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20251008162902_create_ocpp_models.rb +77 -0
- data/db/migrate/20251015190754_make_charging_session_id_nullable_in_meter_values.rb +5 -0
- data/db/migrate/20251016000000_create_ocpp_state_changes.rb +17 -0
- data/db/migrate/20251017000000_create_ocpp_authorizations.rb +15 -0
- data/db/migrate/20260707000000_add_timestamp_provenance_to_ocpp_meter_values.rb +6 -0
- data/db/migrate/20260707000001_convert_ocpp_transaction_id_to_integer.rb +19 -0
- data/db/migrate/20260707000002_add_active_session_guard_to_ocpp_charging_sessions.rb +11 -0
- data/db/migrate/20260707000003_add_anomaly_flags_to_ocpp_meter_values.rb +6 -0
- data/db/migrate/20260707000004_add_auth_password_digest_to_ocpp_charge_points.rb +5 -0
- data/lib/generators/ocpp/rails/install_generator.rb +42 -0
- data/lib/generators/ocpp/rails/templates/README +45 -0
- data/lib/generators/ocpp/rails/templates/create_ocpp_charge_points.rb +21 -0
- data/lib/generators/ocpp/rails/templates/create_ocpp_charging_sessions.rb +22 -0
- data/lib/generators/ocpp/rails/templates/create_ocpp_messages.rb +18 -0
- data/lib/generators/ocpp/rails/templates/create_ocpp_meter_values.rb +21 -0
- data/lib/generators/ocpp/rails/templates/ocpp_rails.rb +13 -0
- data/lib/ocpp/rails/engine.rb +19 -0
- data/lib/ocpp/rails/version.rb +5 -0
- data/lib/ocpp/rails.rb +82 -0
- data/lib/tasks/ocpp/authorizations.rake +18 -0
- data/lib/tasks/ocpp/rails_tasks.rake +4 -0
- data/lib/tasks/ocpp/state_changes.rake +18 -0
- metadata +196 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d4b1fb314e0058f03bfdb12510648169f54fb9ef2b66bd6a49d471cf2ed79a4f
|
|
4
|
+
data.tar.gz: 7ff5bf30b29c6a0ed9f9f131003b14db3691995a04803e0c8814a3bd227850c3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: afecaf81715070d2728b49650b6c84dba24b78de7b518b176fac9f8c0d2446e08c92e7127a082f346d954286854a663961460bcce7b7f2f0788da607aa0cb971
|
|
7
|
+
data.tar.gz: c78be1531480bb503ac6e83b4a08ce708490b22daf8fd969482f7246d7d84c688eb1bc9b0c2dba856ae798296edae984ce5e7153c2bb24c2d97c7beec66fb511
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Jakob Sommerhuber
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
# OCPP Rails
|
|
2
|
+
|
|
3
|
+
[]()
|
|
4
|
+
[](docs/octt-test-plan.md)
|
|
5
|
+
[]()
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
A Ruby on Rails engine for building OCPP 1.6 **Central System** (CSMS) backends for
|
|
9
|
+
Electric Vehicle (EV) charging networks. This is a **backend-only gem** — you build
|
|
10
|
+
your own UI while OCPP Rails handles the WebSocket transport, data models, and the
|
|
11
|
+
OCPP message layer.
|
|
12
|
+
|
|
13
|
+
> **⚠️ Early alpha — read the compliance status before you rely on this.**
|
|
14
|
+
> The Core charging-session flow (boot, authorize, start/stop transaction, meter
|
|
15
|
+
> values, status), remote start/stop, and connector unlock are implemented **and
|
|
16
|
+
> covered by real handler/job-driven tests**. **Most other Central-System→Charge-Point
|
|
17
|
+
> operations and every optional feature profile (Reservation, Smart Charging,
|
|
18
|
+
> Firmware, Diagnostics, Local Auth List, Security certificates) are not implemented yet.**
|
|
19
|
+
> See [OCPP 1.6 Compliance Status](#-ocpp-16-compliance-status) for the exact,
|
|
20
|
+
> per-test-case picture — and please consider [contributing](#-contributing) a slice.
|
|
21
|
+
|
|
22
|
+
## ✨ Features
|
|
23
|
+
|
|
24
|
+
### OCPP Protocol Layer (What This Gem Provides)
|
|
25
|
+
- 📡 **WebSocket Communication** - ActionCable channel handles bidirectional OCPP messages
|
|
26
|
+
- 🔌 **Protocol Handlers** - BootNotification, Authorize, Heartbeat, StartTransaction, StopTransaction, MeterValues, StatusNotification
|
|
27
|
+
- 🗄️ **Data Models** - ChargePoint, ChargingSession, MeterValue, Message (audit log)
|
|
28
|
+
- 🚀 **Remote Control Jobs** - RemoteStartTransaction, RemoteStopTransaction, UnlockConnector, Reset, ClearCache, GetConfiguration, ChangeConfiguration, ChangeAvailability
|
|
29
|
+
- 📊 **Real-time Broadcasts** - ActionCable broadcasts for status, sessions, and meter values
|
|
30
|
+
- 💾 **SQLite Compatible** - Works with async adapter, no Redis required for development
|
|
31
|
+
|
|
32
|
+
### What You Build (Your Application)
|
|
33
|
+
- 🎨 **User Interface** - Build your own dashboard, charts, and controls
|
|
34
|
+
- 🔐 **Authentication** - Implement your own user authentication
|
|
35
|
+
- 📱 **API Endpoints** - Create REST/GraphQL APIs as needed
|
|
36
|
+
- 💼 **Business Logic** - Billing, reservations, user management, etc.
|
|
37
|
+
- 🎯 **Custom Authorization** - Override handlers for RFID validation logic
|
|
38
|
+
|
|
39
|
+
### OCPP Compliance
|
|
40
|
+
- ✅ **Core session flow** — inbound BootNotification, Authorize, Heartbeat, Start/StopTransaction, MeterValues, StatusNotification
|
|
41
|
+
- ✅ **Remote Control** — RemoteStartTransaction, RemoteStopTransaction, UnlockConnector (delivery + end-to-end flow, tested)
|
|
42
|
+
- ✅ **Message Audit** — every inbound/outbound frame logged for debugging and compliance
|
|
43
|
+
- ✅ **Multi-connector** — one active session per connector, enforced at the DB level
|
|
44
|
+
- 🚧 **Everything else** — see the honest, per-test-case [OCPP 1.6 Compliance Status](#-ocpp-16-compliance-status) below
|
|
45
|
+
|
|
46
|
+
## 📋 OCPP 1.6 Compliance Status
|
|
47
|
+
|
|
48
|
+
Compliance here is measured against the Open Charge Alliance **OCPP Compliance Testing
|
|
49
|
+
Tool (OCTT)** test case document (2025-02), Section 3 — the cases that apply when the
|
|
50
|
+
*System Under Test is the Central System*. There are **76** such cases. This is the
|
|
51
|
+
role `ocpp-rails` fills, so it is the right yardstick.
|
|
52
|
+
|
|
53
|
+
**Where we are today:**
|
|
54
|
+
|
|
55
|
+
| | Cases | What it means |
|
|
56
|
+
|---|---:|---|
|
|
57
|
+
| ✅ **Implemented + tested** | 32 | Works and guarded by a real handler/job-driven test |
|
|
58
|
+
| 🟡 **Implemented — needs test** | 0 | Behavior works, but only simulation-style tests exist; needs a real regression test |
|
|
59
|
+
| 🔴 **Not implemented** | 42 | The message/operation does not exist in the engine yet |
|
|
60
|
+
| ⚪ **Out of scope** | 2 | TLS handshake (TC_086/087) — belongs in your infra, not app code |
|
|
61
|
+
|
|
62
|
+
So **32 of 76 OCTT Central-System cases (42%) are backed by working code**, and every
|
|
63
|
+
one now has real automated (handler/job-driven) coverage — the 🟡 bucket is empty. The
|
|
64
|
+
entire Core outbound command set is done; the rest — Local Auth List, Firmware,
|
|
65
|
+
Diagnostics, Reservation, Remote Trigger, Smart Charging, DataTransfer and the Security
|
|
66
|
+
profiles 2/3 — is **not built yet**. Treat this gem as a solid, well-tested Core-profile
|
|
67
|
+
foundation to build on, not a certified CSMS.
|
|
68
|
+
|
|
69
|
+
**By feature area** (each links to the detailed Given/When/Then specs):
|
|
70
|
+
|
|
71
|
+
| Area | Status | Notes |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| [Boot / Charging Sessions / Cache](docs/octt-test-plan.md#1-boot-charging-sessions-cache) | ✅ implemented + tested | Core flow **and** ClearCache tested end-to-end |
|
|
74
|
+
| [Remote Start / Stop](docs/octt-test-plan.md#2-remote-start--stop) | ✅ implemented + tested | delivery **and** end-to-end session flow tested |
|
|
75
|
+
| [Reset / Unlock / Configuration](docs/octt-test-plan.md#3-reset--unlock--configuration-core-profile) | ✅ implemented + tested | Reset, UnlockConnector, Get/ChangeConfiguration all tested (+ ChangeAvailability) |
|
|
76
|
+
| [Authorize non-happy paths](docs/octt-test-plan.md#4-authorize-non-happy-paths) | ✅ tested | Invalid / Expired / Blocked on Authorize.req tested |
|
|
77
|
+
| [Offline / power-loss](docs/octt-test-plan.md#5-offline--power-loss-behavior) | ✅ tested | replay + power-loss recovery sequences tested |
|
|
78
|
+
| [Local Authorization List](docs/octt-test-plan.md#6-local-authorization-list) | 🔴 not implemented | no SendLocalList / GetLocalListVersion |
|
|
79
|
+
| [Firmware Management](docs/octt-test-plan.md#7-firmware-management) | 🔴 not implemented | no UpdateFirmware / FirmwareStatusNotification |
|
|
80
|
+
| [Diagnostics](docs/octt-test-plan.md#8-diagnostics) | 🔴 not implemented | no GetDiagnostics / DiagnosticsStatusNotification |
|
|
81
|
+
| [Reservation](docs/octt-test-plan.md#9-reservation) | 🔴 not implemented | no ReserveNow / CancelReservation, no model |
|
|
82
|
+
| [Remote Trigger](docs/octt-test-plan.md#10-remotetrigger) | 🔴 not implemented | no TriggerMessage |
|
|
83
|
+
| [Smart Charging](docs/octt-test-plan.md#11-smart-charging) | 🔴 not implemented | no SetChargingProfile / ClearChargingProfile / GetCompositeSchedule |
|
|
84
|
+
| [DataTransfer](docs/octt-test-plan.md#12-datatransfer) | 🔴 not implemented | inbound DataTransfer currently gets a NotSupported CALLERROR |
|
|
85
|
+
| [Security (profiles 1–3)](docs/octt-test-plan.md#13-security-profiles-13) | 🟡 Basic auth only | HTTP Basic Auth works + tested; certificates/secure firmware/TLS not |
|
|
86
|
+
|
|
87
|
+
👉 **Full per-test-case breakdown with Given/When/Then specs:** [docs/octt-test-plan.md](docs/octt-test-plan.md).
|
|
88
|
+
It doubles as a ready-made contribution backlog — every 🔴 and 🟡 is a self-contained PR.
|
|
89
|
+
|
|
90
|
+
## 🚀 Quick Start
|
|
91
|
+
|
|
92
|
+
### Installation
|
|
93
|
+
|
|
94
|
+
Add this line to your application's Gemfile:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
gem "ocpp-rails"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Execute the bundle command:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
bundle install
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Setup
|
|
107
|
+
|
|
108
|
+
Run the installation generator:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
rails generate ocpp:rails:install
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
This will:
|
|
115
|
+
- ✅ Create database migrations for charge points, sessions, and meter values
|
|
116
|
+
- ✅ Mount the engine at `/ocpp` (ActionCable WebSocket endpoint)
|
|
117
|
+
- ✅ Generate an initializer at `config/initializers/ocpp_rails.rb`
|
|
118
|
+
- ✅ Configure ActionCable for SQLite compatibility
|
|
119
|
+
- ✅ Display setup instructions
|
|
120
|
+
|
|
121
|
+
Run the migrations:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
rails db:migrate
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Configuration
|
|
128
|
+
|
|
129
|
+
Configure OCPP settings in `config/initializers/ocpp_rails.rb`:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
Ocpp::Rails.setup do |config|
|
|
133
|
+
config.ocpp_version = "1.6"
|
|
134
|
+
config.supported_versions = ["1.6"]
|
|
135
|
+
config.heartbeat_interval = 300 # 5 minutes
|
|
136
|
+
config.connection_timeout = 30 # 30 seconds
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**Charge points connect to:**
|
|
141
|
+
```
|
|
142
|
+
ws://your-server:3000/ocpp/cable
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Stations authenticate with HTTP Basic Auth on the WebSocket upgrade
|
|
146
|
+
(OCPP-J Security Profile 1, enabled by default). Provision a per-station
|
|
147
|
+
credential first:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
charge_point.update!(auth_password: SecureRandom.base58(32))
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
For detailed setup instructions, see the [Getting Started Guide](docs/getting-started.md)
|
|
154
|
+
and the [Security Guide](docs/security.md).
|
|
155
|
+
|
|
156
|
+
## 💡 Usage Examples
|
|
157
|
+
|
|
158
|
+
### Monitor Charge Point Status
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
# Query charge points
|
|
162
|
+
connected_cps = Ocpp::Rails::ChargePoint.connected
|
|
163
|
+
available_cps = Ocpp::Rails::ChargePoint.available
|
|
164
|
+
charging_cps = Ocpp::Rails::ChargePoint.charging
|
|
165
|
+
|
|
166
|
+
# Check specific charge point
|
|
167
|
+
cp = Ocpp::Rails::ChargePoint.find_by(identifier: "CP001")
|
|
168
|
+
cp.connected? # => true/false
|
|
169
|
+
cp.status # => "Available", "Charging", etc.
|
|
170
|
+
cp.last_heartbeat_at
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Monitor Active Sessions
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
# Get active sessions
|
|
177
|
+
active_sessions = Ocpp::Rails::ChargingSession.active
|
|
178
|
+
|
|
179
|
+
# Get session details
|
|
180
|
+
session = cp.current_session
|
|
181
|
+
session.connector_id
|
|
182
|
+
session.energy_consumed # kWh
|
|
183
|
+
session.duration_seconds
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Monitor Meter Values
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
# Get latest readings
|
|
190
|
+
latest_energy = cp.meter_values.energy.recent.first
|
|
191
|
+
latest_power = cp.meter_values.power.recent.first
|
|
192
|
+
|
|
193
|
+
# Get readings for a session
|
|
194
|
+
session.meter_values.order(:timestamp)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Real-Time Updates via ActionCable
|
|
198
|
+
|
|
199
|
+
Subscribe to real-time broadcasts in your UI:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
# app/channels/meter_values_channel.rb
|
|
203
|
+
class MeterValuesChannel < ApplicationCable::Channel
|
|
204
|
+
def subscribed
|
|
205
|
+
charge_point = Ocpp::Rails::ChargePoint.find(params[:charge_point_id])
|
|
206
|
+
stream_from "charge_point_#{charge_point.id}_meter_values"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
```javascript
|
|
212
|
+
// Subscribe in JavaScript
|
|
213
|
+
subscribeToMeterValues(chargePointId, {
|
|
214
|
+
onPower: (data) => {
|
|
215
|
+
updatePowerGauge(data.value);
|
|
216
|
+
},
|
|
217
|
+
onEnergy: (data) => {
|
|
218
|
+
updateEnergyCounter(data.value);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Remote Control
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
# Start a charging session
|
|
227
|
+
charge_point = Ocpp::Rails::ChargePoint.find_by(identifier: "CP001")
|
|
228
|
+
|
|
229
|
+
Ocpp::Rails::RemoteStartTransactionJob.perform_later(
|
|
230
|
+
charge_point.id,
|
|
231
|
+
1, # connector_id
|
|
232
|
+
"RFID12345" # id_tag
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Stop a charging session
|
|
236
|
+
session = charge_point.current_session
|
|
237
|
+
Ocpp::Rails::RemoteStopTransactionJob.perform_later(
|
|
238
|
+
charge_point.id,
|
|
239
|
+
session.transaction_id
|
|
240
|
+
)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
For complete implementation examples, see the [Remote Charging Guide](docs/remote-charging.md).
|
|
244
|
+
|
|
245
|
+
## 📚 Documentation
|
|
246
|
+
|
|
247
|
+
### Getting Started
|
|
248
|
+
- **[Installation & Setup Guide](docs/getting-started.md)** - Detailed installation, configuration, and first steps
|
|
249
|
+
- **[Configuration Options](docs/configuration.md)** - Complete configuration reference
|
|
250
|
+
|
|
251
|
+
### Implementation Guides
|
|
252
|
+
- **[Real-Time Monitoring Guide](docs/real-time-monitoring.md)** - Monitor charge points, sessions, and meter values in real-time
|
|
253
|
+
- **[Remote Charging Implementation](docs/remote-charging.md)** - Complete guide to remote charging workflow with message diagrams
|
|
254
|
+
- **[ActionCable with SQLite](docs/actioncable-sqlite.md)** - WebSocket configuration using SQLite (no Redis required)
|
|
255
|
+
- **[API Reference](docs/api-reference.md)** - Models, jobs, and helper methods
|
|
256
|
+
- **[Message Reference](docs/message-reference.md)** - OCPP message examples and payloads
|
|
257
|
+
|
|
258
|
+
### Development
|
|
259
|
+
- **[Testing Guide](docs/testing.md)** - Running tests, writing new tests, test coverage
|
|
260
|
+
- **[Troubleshooting](docs/troubleshooting.md)** - Common issues and solutions
|
|
261
|
+
|
|
262
|
+
### Reference
|
|
263
|
+
- **[OCPP 1.6 Specification](ocpp-1.6_edition_2.md)** - Full OCPP 1.6 Edition 2 specification
|
|
264
|
+
- **[Documentation Index](docs/README.md)** - Complete documentation overview
|
|
265
|
+
|
|
266
|
+
## 🏗️ Architecture
|
|
267
|
+
|
|
268
|
+
### Models
|
|
269
|
+
|
|
270
|
+
- **`Ocpp::Rails::ChargePoint`** - Represents a physical charging station
|
|
271
|
+
- Tracks connection status, firmware version, vendor info
|
|
272
|
+
- Has many charging sessions and meter values
|
|
273
|
+
- Provides status scopes (connected, available, charging)
|
|
274
|
+
|
|
275
|
+
- **`Ocpp::Rails::ChargingSession`** - Represents a charging transaction
|
|
276
|
+
- Manages session lifecycle (active/completed)
|
|
277
|
+
- Calculates energy consumption and duration automatically
|
|
278
|
+
- Links to meter values for detailed tracking
|
|
279
|
+
|
|
280
|
+
- **`Ocpp::Rails::MeterValue`** - Stores individual meter readings
|
|
281
|
+
- Supports 22+ OCPP measurands (Energy, Power, Current, Voltage, SoC, Temperature, etc.)
|
|
282
|
+
- Can be transaction-specific or standalone
|
|
283
|
+
- Includes timestamp, phase, context, and location
|
|
284
|
+
|
|
285
|
+
- **`Ocpp::Rails::Message`** - Logs all OCPP communications
|
|
286
|
+
- Stores direction (inbound/outbound)
|
|
287
|
+
- Includes full message payload as JSONB
|
|
288
|
+
- Enables debugging and compliance auditing
|
|
289
|
+
|
|
290
|
+
### Communication Flow
|
|
291
|
+
|
|
292
|
+
```
|
|
293
|
+
┌─────────────────┐ ┌─────────────────┐
|
|
294
|
+
│ Your Rails App │ │ Charge Point │
|
|
295
|
+
│ (Central System)│ │ (OCPP 1.6) │
|
|
296
|
+
└────────┬────────┘ └────────┬────────┘
|
|
297
|
+
│ │
|
|
298
|
+
│ 1. RemoteStartTransaction │
|
|
299
|
+
├─────────────────────────────────────>│
|
|
300
|
+
│ │
|
|
301
|
+
│ 2. StartTransaction │
|
|
302
|
+
│<─────────────────────────────────────┤
|
|
303
|
+
│ │
|
|
304
|
+
│ 3. MeterValues (periodic) │
|
|
305
|
+
│<─────────────────────────────────────┤
|
|
306
|
+
│ │
|
|
307
|
+
│ 4. RemoteStopTransaction │
|
|
308
|
+
├─────────────────────────────────────>│
|
|
309
|
+
│ │
|
|
310
|
+
│ 5. StopTransaction │
|
|
311
|
+
│<─────────────────────────────────────┤
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## 🧪 Testing
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
# Run all tests
|
|
318
|
+
rails test
|
|
319
|
+
|
|
320
|
+
# Run the handler/job-driven unit tests (the ones that exercise real code)
|
|
321
|
+
rails test test/ocpp/
|
|
322
|
+
|
|
323
|
+
# Run a specific test file
|
|
324
|
+
rails test test/ocpp/outbound_delivery_test.rb
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
**A note on what the tests prove.** The suite has two kinds of tests:
|
|
328
|
+
|
|
329
|
+
- **Handler/job-driven tests** push real frames through
|
|
330
|
+
`MessageHandler`/`Actions::*Handler`/jobs and assert on actual behavior — e.g.
|
|
331
|
+
`test/ocpp/message_handler_test.rb`, `outbound_delivery_test.rb`,
|
|
332
|
+
`station_authentication_test.rb`, `start_transaction_authorization_test.rb`,
|
|
333
|
+
`authorize_handler_test.rb`, `boot_notification_handler_test.rb`,
|
|
334
|
+
`status_notification_handler_test.rb`, `unlock_connector_job_test.rb`, and several
|
|
335
|
+
under `test/ocpp/integration/` (`remote_start_flow_test.rb`, `remote_stop_flow_test.rb`,
|
|
336
|
+
`offline_transaction_test.rb`, `power_failure_recovery_test.rb`). **These are what the
|
|
337
|
+
compliance status above counts.**
|
|
338
|
+
- **Simulation-style tests** — the older files under `test/ocpp/integration/` (e.g.
|
|
339
|
+
`*_transaction_test.rb`, `remote_charging_session_workflow_test.rb`, `authorize_test.rb`,
|
|
340
|
+
`boot_notification_test.rb`) build request/response hashes and model rows by hand
|
|
341
|
+
*without* invoking production code. They document expected message shapes but do **not**
|
|
342
|
+
prove wire-protocol compliance, so they are not counted toward OCTT coverage.
|
|
343
|
+
|
|
344
|
+
When you implement a 🔴/🟡 case from the [test plan](docs/octt-test-plan.md), please add
|
|
345
|
+
a **handler/job-driven** test for it. See the [Testing Guide](docs/testing.md) for details.
|
|
346
|
+
|
|
347
|
+
## 🗺️ Roadmap
|
|
348
|
+
|
|
349
|
+
### Implemented today
|
|
350
|
+
- ✅ Core inbound session flow (Boot, Authorize, Heartbeat, Start/StopTransaction, MeterValues, StatusNotification)
|
|
351
|
+
- ✅ Remote start/stop transactions (delivery + end-to-end flow) and UnlockConnector — releasable even during an active session
|
|
352
|
+
- ✅ Device management: Reset (Hard/Soft), ClearCache, ChangeAvailability, and Get/ChangeConfiguration
|
|
353
|
+
- ✅ Real-time meter value / status / session broadcasts
|
|
354
|
+
- ✅ Session management, energy/duration tracking, meter-anomaly + timestamp-provenance checks
|
|
355
|
+
- ✅ OCPP-J Security Profile 1 (HTTP Basic Auth) + per-station rate limiting
|
|
356
|
+
- ✅ Complete inbound/outbound message logging
|
|
357
|
+
- ✅ Handler/job-driven OCTT regression suite for all 32 implemented Core-profile cases
|
|
358
|
+
|
|
359
|
+
### Not implemented yet (contributions very welcome — see the [test plan](docs/octt-test-plan.md))
|
|
360
|
+
- 🔴 Local Authorization List (SendLocalList, GetLocalListVersion)
|
|
361
|
+
- 🔴 Firmware updates + FirmwareStatusNotification
|
|
362
|
+
- 🔴 Diagnostics upload + DiagnosticsStatusNotification
|
|
363
|
+
- 🔴 Reservation system (ReserveNow, CancelReservation)
|
|
364
|
+
- 🔴 Remote Trigger (TriggerMessage)
|
|
365
|
+
- 🔴 Smart charging profile management (Set/Clear/GetCompositeSchedule)
|
|
366
|
+
- 🔴 Inbound DataTransfer handling
|
|
367
|
+
- 🔴 Security profiles 2/3 (certificate management, secure firmware, security events)
|
|
368
|
+
- 📝 OCPP 2.0.1 support
|
|
369
|
+
|
|
370
|
+
## 🤝 Contributing
|
|
371
|
+
|
|
372
|
+
**This project needs you.** It's an honest, well-tested Core-profile foundation with a
|
|
373
|
+
long list of OCPP features still to build — and that list is already written up as a
|
|
374
|
+
ready-to-pick-up backlog.
|
|
375
|
+
|
|
376
|
+
### Where to start
|
|
377
|
+
|
|
378
|
+
[**docs/octt-test-plan.md**](docs/octt-test-plan.md) maps all 76 OCTT Central-System
|
|
379
|
+
test cases to their status. Every 🔴 (*not implemented*) and 🟡 (*needs a real test*)
|
|
380
|
+
entry comes with a Given/When/Then spec and a suggested test file — so each one is a
|
|
381
|
+
self-contained, well-scoped PR. Good first issues:
|
|
382
|
+
|
|
383
|
+
- **🟡 Add a real test** for something that already works — pick a 🟡 case (e.g. the
|
|
384
|
+
Authorize non-happy paths, or the boot flow) and write a handler-driven test against
|
|
385
|
+
the spec. No new production code needed.
|
|
386
|
+
- **🔴 Implement one operation** — the outbound Core command set is complete; the next
|
|
387
|
+
self-contained targets are Remote Trigger (`TriggerMessage`) and the Local Auth List
|
|
388
|
+
commands (`GetLocalListVersion`, `SendLocalList`), following the same job pattern as
|
|
389
|
+
`RemoteStartTransactionJob` / `ResetJob`.
|
|
390
|
+
- **🔴 Build out a feature profile** — Reservation, Smart Charging, Firmware, etc. are
|
|
391
|
+
larger efforts; open an issue first so we can sketch the model/API together.
|
|
392
|
+
|
|
393
|
+
### Ground rules
|
|
394
|
+
|
|
395
|
+
- Match the OCTT spec in the test plan — cite the `TC_xxx_CSMS` id in your PR.
|
|
396
|
+
- Add a **handler/job-driven** test (not a simulation-style hash test) for any behavior
|
|
397
|
+
you implement, and update the case's status in `docs/octt-test-plan.md` and the summary
|
|
398
|
+
in this README.
|
|
399
|
+
- For major changes, open an issue first to discuss the design.
|
|
400
|
+
|
|
401
|
+
### Development Setup
|
|
402
|
+
|
|
403
|
+
```bash
|
|
404
|
+
# Clone the repository
|
|
405
|
+
git clone https://github.com/trahfo/ocpp-rails.git
|
|
406
|
+
cd ocpp-rails
|
|
407
|
+
|
|
408
|
+
# Install dependencies
|
|
409
|
+
bundle install
|
|
410
|
+
|
|
411
|
+
# Setup test database
|
|
412
|
+
cd test/dummy
|
|
413
|
+
rails db:migrate RAILS_ENV=test
|
|
414
|
+
cd ../..
|
|
415
|
+
|
|
416
|
+
# Run tests
|
|
417
|
+
rails test
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
### Running the Test Suite
|
|
421
|
+
|
|
422
|
+
```bash
|
|
423
|
+
# Run all tests with coverage
|
|
424
|
+
rails test
|
|
425
|
+
|
|
426
|
+
# Run with verbose output
|
|
427
|
+
rails test -v
|
|
428
|
+
|
|
429
|
+
# Run specific test file
|
|
430
|
+
rails test test/ocpp/integration/remote_charging_session_workflow_test.rb
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
## 📄 License
|
|
434
|
+
|
|
435
|
+
This gem is available as open source under the terms of the [MIT License](LICENSE).
|
|
436
|
+
|
|
437
|
+
## 🙏 Acknowledgments
|
|
438
|
+
|
|
439
|
+
- [Open Charge Alliance](https://www.openchargealliance.org/) for the OCPP specification
|
|
440
|
+
- Built with ❤️ for the EV charging community
|
|
441
|
+
|
|
442
|
+
## 📞 Support
|
|
443
|
+
|
|
444
|
+
- **Documentation**: [docs/](docs/)
|
|
445
|
+
- **Issues**: [GitHub Issues](https://github.com/trahfo/ocpp-rails/issues)
|
|
446
|
+
- **Discussions**: [GitHub Discussions](https://github.com/trahfo/ocpp-rails/discussions)
|
|
447
|
+
|
|
448
|
+
## 🔗 Links
|
|
449
|
+
|
|
450
|
+
- [Changelog](CHANGELOG.md)
|
|
451
|
+
- [OCPP 1.6 Specification](ocpp-1.6_edition_2.md)
|
|
452
|
+
- [Complete Documentation](docs/README.md)
|
|
453
|
+
- [API Documentation](docs/api-reference.md)
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
**Status**: 🔧 Alpha - Under Active Development
|
|
458
|
+
**Version**: 0.2.0
|
|
459
|
+
**OCPP**: 1.6 Edition 2
|
|
460
|
+
**Rails**: 8.0+
|
|
461
|
+
**Ruby**: 3.3+
|
data/Rakefile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
|
3
|
+
* listed below.
|
|
4
|
+
*
|
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
|
7
|
+
*
|
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
|
11
|
+
* It is generally better to create a new file per style scope.
|
|
12
|
+
*
|
|
13
|
+
*= require_tree .
|
|
14
|
+
*= require_self
|
|
15
|
+
*/
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module Ocpp
|
|
2
|
+
module Rails
|
|
3
|
+
class ChargePointChannel < ActionCable::Channel::Base
|
|
4
|
+
# Charge point subscribes with their identifier; the credential travels
|
|
5
|
+
# in the HTTP Basic Authorization header of the WebSocket upgrade
|
|
6
|
+
# (OCPP-J Security Profile 1).
|
|
7
|
+
def subscribed
|
|
8
|
+
charge_point_id = params[:charge_point_id]
|
|
9
|
+
|
|
10
|
+
unless Ocpp::Rails.connection_rate_limiter.allow?(charge_point_id.to_s)
|
|
11
|
+
reject
|
|
12
|
+
logger.warn "[OCPP][security] ChargePoint #{charge_point_id} subscription rejected: connection rate limit exceeded"
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
result = StationAuthenticator.authenticate(
|
|
17
|
+
identifier: charge_point_id,
|
|
18
|
+
authorization_header: authorization_header
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
unless result.success?
|
|
22
|
+
reject
|
|
23
|
+
logger.warn "[OCPP][security] ChargePoint #{charge_point_id} subscription rejected: #{result.failure}"
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@charge_point = result.charge_point
|
|
28
|
+
stream_for @charge_point
|
|
29
|
+
old_connected = @charge_point.connected
|
|
30
|
+
@charge_point.update(connected: true, last_heartbeat_at: Time.current)
|
|
31
|
+
logger.info "ChargePoint #{charge_point_id} connected"
|
|
32
|
+
|
|
33
|
+
# Log connection state change if it actually changed
|
|
34
|
+
log_connection_change(old_connected, true, "subscribed")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def unsubscribed
|
|
38
|
+
if @charge_point
|
|
39
|
+
old_connected = @charge_point.connected
|
|
40
|
+
@charge_point.disconnect!
|
|
41
|
+
logger.info "ChargePoint #{@charge_point.identifier} disconnected"
|
|
42
|
+
|
|
43
|
+
# Log connection state change if it actually changed
|
|
44
|
+
log_connection_change(old_connected, false, "unsubscribed")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Receive OCPP messages from charge point
|
|
49
|
+
def receive(data)
|
|
50
|
+
return unless @charge_point
|
|
51
|
+
|
|
52
|
+
# Drop before any processing or Message-row write, so a chatty or
|
|
53
|
+
# malicious station cannot grow the database unboundedly.
|
|
54
|
+
unless Ocpp::Rails.message_rate_limiter.allow?(@charge_point.identifier)
|
|
55
|
+
logger.warn "[OCPP][security] ChargePoint #{@charge_point.identifier}: message rate limit exceeded, dropping message"
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
message = data["message"]
|
|
60
|
+
MessageHandler.new(@charge_point, message).process
|
|
61
|
+
rescue => e
|
|
62
|
+
logger.error "Error receiving message from ChargePoint #{@charge_point&.identifier}: #{e.message}"
|
|
63
|
+
logger.error e.backtrace.join("\n")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# The connection's request is protected API; ConnectionStub in channel
|
|
69
|
+
# tests may not define it at all, and :none mode must keep working then.
|
|
70
|
+
def authorization_header
|
|
71
|
+
return nil unless connection.respond_to?(:request, true)
|
|
72
|
+
|
|
73
|
+
connection.send(:request).headers["Authorization"]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def log_connection_change(old_connected, new_connected, source)
|
|
77
|
+
return if old_connected == new_connected
|
|
78
|
+
|
|
79
|
+
begin
|
|
80
|
+
Ocpp::Rails::StateChange.create!(
|
|
81
|
+
charge_point: @charge_point,
|
|
82
|
+
change_type: "connection",
|
|
83
|
+
connector_id: nil,
|
|
84
|
+
old_value: old_connected.to_s,
|
|
85
|
+
new_value: new_connected.to_s,
|
|
86
|
+
metadata: { source: source }
|
|
87
|
+
)
|
|
88
|
+
rescue => error
|
|
89
|
+
logger.error("Failed to log state change: #{error.message}")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Ocpp
|
|
2
|
+
module Rails
|
|
3
|
+
class AsyncHookJob < ApplicationJob
|
|
4
|
+
queue_as :ocpp_hooks
|
|
5
|
+
|
|
6
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 3
|
|
7
|
+
|
|
8
|
+
def perform(state_change_id, hook_class_name)
|
|
9
|
+
state_change = Ocpp::Rails::StateChange.find_by(id: state_change_id)
|
|
10
|
+
|
|
11
|
+
unless state_change
|
|
12
|
+
::Rails.logger.warn("StateChange #{state_change_id} not found, may have been cleaned up")
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
hook_class = hook_class_name.constantize
|
|
17
|
+
hook = hook_class.new
|
|
18
|
+
hook.call(state_change)
|
|
19
|
+
rescue => error
|
|
20
|
+
::Rails.logger.error("AsyncHook #{hook_class_name} failed for StateChange #{state_change_id}: #{error.message}")
|
|
21
|
+
raise
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Ocpp
|
|
2
|
+
module Rails
|
|
3
|
+
class AuthorizationAsyncHookJob < ApplicationJob
|
|
4
|
+
queue_as :ocpp_hooks
|
|
5
|
+
|
|
6
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 3
|
|
7
|
+
|
|
8
|
+
def perform(authorization_id, hook_class_name)
|
|
9
|
+
authorization = Ocpp::Rails::Authorization.find_by(id: authorization_id)
|
|
10
|
+
|
|
11
|
+
unless authorization
|
|
12
|
+
::Rails.logger.warn("Authorization #{authorization_id} not found, may have been cleaned up")
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
hook_class = hook_class_name.constantize
|
|
17
|
+
hook = hook_class.new
|
|
18
|
+
hook.call(authorization)
|
|
19
|
+
rescue => error
|
|
20
|
+
::Rails.logger.error("AuthorizationAsyncHook #{hook_class_name} failed for Authorization #{authorization_id}: #{error.message}")
|
|
21
|
+
raise
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|