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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +461 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/stylesheets/ocpp/rails/application.css +15 -0
  6. data/app/channels/ocpp/rails/charge_point_channel.rb +94 -0
  7. data/app/controllers/ocpp/rails/application_controller.rb +7 -0
  8. data/app/helpers/ocpp/rails/application_helper.rb +6 -0
  9. data/app/jobs/ocpp/rails/application_job.rb +6 -0
  10. data/app/jobs/ocpp/rails/async_hook_job.rb +25 -0
  11. data/app/jobs/ocpp/rails/authorization_async_hook_job.rb +25 -0
  12. data/app/jobs/ocpp/rails/change_availability_job.rb +42 -0
  13. data/app/jobs/ocpp/rails/change_configuration_job.rb +41 -0
  14. data/app/jobs/ocpp/rails/cleanup_authorizations_job.rb +28 -0
  15. data/app/jobs/ocpp/rails/cleanup_state_changes_job.rb +28 -0
  16. data/app/jobs/ocpp/rails/clear_cache_job.rb +37 -0
  17. data/app/jobs/ocpp/rails/get_configuration_job.rb +38 -0
  18. data/app/jobs/ocpp/rails/remote_start_transaction_job.rb +39 -0
  19. data/app/jobs/ocpp/rails/remote_stop_transaction_job.rb +38 -0
  20. data/app/jobs/ocpp/rails/reset_job.rb +39 -0
  21. data/app/jobs/ocpp/rails/unlock_connector_job.rb +38 -0
  22. data/app/mailers/ocpp/rails/application_mailer.rb +8 -0
  23. data/app/models/ocpp/rails/application_record.rb +7 -0
  24. data/app/models/ocpp/rails/authorization.rb +26 -0
  25. data/app/models/ocpp/rails/charge_point.rb +69 -0
  26. data/app/models/ocpp/rails/charging_session.rb +72 -0
  27. data/app/models/ocpp/rails/message.rb +17 -0
  28. data/app/models/ocpp/rails/meter_value.rb +24 -0
  29. data/app/models/ocpp/rails/state_change.rb +26 -0
  30. data/app/services/ocpp/rails/actions/authorize_handler.rb +47 -0
  31. data/app/services/ocpp/rails/actions/boot_notification_handler.rb +62 -0
  32. data/app/services/ocpp/rails/actions/heartbeat_handler.rb +25 -0
  33. data/app/services/ocpp/rails/actions/meter_values_handler.rb +97 -0
  34. data/app/services/ocpp/rails/actions/start_transaction_handler.rb +126 -0
  35. data/app/services/ocpp/rails/actions/status_notification_handler.rb +99 -0
  36. data/app/services/ocpp/rails/actions/stop_transaction_handler.rb +121 -0
  37. data/app/services/ocpp/rails/authorization_hook_manager.rb +102 -0
  38. data/app/services/ocpp/rails/message_handler.rb +153 -0
  39. data/app/services/ocpp/rails/meter_anomaly_detector.rb +50 -0
  40. data/app/services/ocpp/rails/protocol.rb +65 -0
  41. data/app/services/ocpp/rails/rate_limiter.rb +53 -0
  42. data/app/services/ocpp/rails/state_change_hook_manager.rb +26 -0
  43. data/app/services/ocpp/rails/station_authenticator.rb +46 -0
  44. data/app/services/ocpp/rails/timestamp_parser.rb +25 -0
  45. data/config/routes.rb +5 -0
  46. data/db/migrate/20251008162902_create_ocpp_models.rb +77 -0
  47. data/db/migrate/20251015190754_make_charging_session_id_nullable_in_meter_values.rb +5 -0
  48. data/db/migrate/20251016000000_create_ocpp_state_changes.rb +17 -0
  49. data/db/migrate/20251017000000_create_ocpp_authorizations.rb +15 -0
  50. data/db/migrate/20260707000000_add_timestamp_provenance_to_ocpp_meter_values.rb +6 -0
  51. data/db/migrate/20260707000001_convert_ocpp_transaction_id_to_integer.rb +19 -0
  52. data/db/migrate/20260707000002_add_active_session_guard_to_ocpp_charging_sessions.rb +11 -0
  53. data/db/migrate/20260707000003_add_anomaly_flags_to_ocpp_meter_values.rb +6 -0
  54. data/db/migrate/20260707000004_add_auth_password_digest_to_ocpp_charge_points.rb +5 -0
  55. data/lib/generators/ocpp/rails/install_generator.rb +42 -0
  56. data/lib/generators/ocpp/rails/templates/README +45 -0
  57. data/lib/generators/ocpp/rails/templates/create_ocpp_charge_points.rb +21 -0
  58. data/lib/generators/ocpp/rails/templates/create_ocpp_charging_sessions.rb +22 -0
  59. data/lib/generators/ocpp/rails/templates/create_ocpp_messages.rb +18 -0
  60. data/lib/generators/ocpp/rails/templates/create_ocpp_meter_values.rb +21 -0
  61. data/lib/generators/ocpp/rails/templates/ocpp_rails.rb +13 -0
  62. data/lib/ocpp/rails/engine.rb +19 -0
  63. data/lib/ocpp/rails/version.rb +5 -0
  64. data/lib/ocpp/rails.rb +82 -0
  65. data/lib/tasks/ocpp/authorizations.rake +18 -0
  66. data/lib/tasks/ocpp/rails_tasks.rake +4 -0
  67. data/lib/tasks/ocpp/state_changes.rake +18 -0
  68. 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
+ [![OCPP](https://img.shields.io/badge/OCPP-1.6-blue)]()
4
+ [![OCTT CS coverage](https://img.shields.io/badge/OCTT%20Central%20System-32%2F76%20cases-orange)](docs/octt-test-plan.md)
5
+ [![Status](https://img.shields.io/badge/status-alpha-yellow)]()
6
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -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,7 @@
1
+ module Ocpp
2
+ module Rails
3
+ class ApplicationController < ActionController::Base
4
+ protect_from_forgery with: :exception
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ module Ocpp
2
+ module Rails
3
+ module ApplicationHelper
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module Ocpp
2
+ module Rails
3
+ class ApplicationJob < ActiveJob::Base
4
+ end
5
+ end
6
+ 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