ocpp-rails 0.2.4 → 0.3.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4b1fb314e0058f03bfdb12510648169f54fb9ef2b66bd6a49d471cf2ed79a4f
4
- data.tar.gz: 7ff5bf30b29c6a0ed9f9f131003b14db3691995a04803e0c8814a3bd227850c3
3
+ metadata.gz: 42e87a1079adc1b8d7641883fa8e57b21d07ff38a5b150dd231a64d637d37a47
4
+ data.tar.gz: f4fd46249622b68f30f035c99da715913219940eabb2dabdefc8e15255840a84
5
5
  SHA512:
6
- metadata.gz: afecaf81715070d2728b49650b6c84dba24b78de7b518b176fac9f8c0d2446e08c92e7127a082f346d954286854a663961460bcce7b7f2f0788da607aa0cb971
7
- data.tar.gz: c78be1531480bb503ac6e83b4a08ce708490b22daf8fd969482f7246d7d84c688eb1bc9b0c2dba856ae798296edae984ce5e7153c2bb24c2d97c7beec66fb511
6
+ metadata.gz: b103ecc7ed7e89acdd7aad80e93314cffe57f8c3fd84664a3cbd8da00e10a60f0a7e2b8c03870b27f58ee2fbf93e89b09f020e88db274a776a79d32b6a5f6f4f
7
+ data.tar.gz: 3880fb4073c68ece89623df6740b529684c7a71f284dab18b4080528418ba31d339cf00565de3039d867ec0e6d757322e26b4970ba73d27425dfc433e93cdb8d
data/README.md CHANGED
@@ -24,7 +24,8 @@ OCPP message layer.
24
24
  ### OCPP Protocol Layer (What This Gem Provides)
25
25
  - 📡 **WebSocket Communication** - ActionCable channel handles bidirectional OCPP messages
26
26
  - 🔌 **Protocol Handlers** - BootNotification, Authorize, Heartbeat, StartTransaction, StopTransaction, MeterValues, StatusNotification
27
- - 🗄️ **Data Models** - ChargePoint, ChargingSession, MeterValue, Message (audit log)
27
+ - 🗄️ **Data Models** - ChargePoint, ConnectorStatus, ChargingSession, MeterValue, Message (audit log)
28
+ - 🪝 **Lifecycle Hooks** - authorization, state-change, and session-start/stop hooks (sync or async via ActiveJob) — react without polling
28
29
  - 🚀 **Remote Control Jobs** - RemoteStartTransaction, RemoteStopTransaction, UnlockConnector, Reset, ClearCache, GetConfiguration, ChangeConfiguration, ChangeAvailability
29
30
  - 📊 **Real-time Broadcasts** - ActionCable broadcasts for status, sessions, and meter values
30
31
  - 💾 **SQLite Compatible** - Works with async adapter, no Redis required for development
@@ -40,7 +41,7 @@ OCPP message layer.
40
41
  - ✅ **Core session flow** — inbound BootNotification, Authorize, Heartbeat, Start/StopTransaction, MeterValues, StatusNotification
41
42
  - ✅ **Remote Control** — RemoteStartTransaction, RemoteStopTransaction, UnlockConnector (delivery + end-to-end flow, tested)
42
43
  - ✅ **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
+ - ✅ **Multi-connector** — one active session per connector, enforced at the DB level; per-connector status tracked independently of whole-station status
44
45
  - 🚧 **Everything else** — see the honest, per-test-case [OCPP 1.6 Compliance Status](#-ocpp-16-compliance-status) below
45
46
 
46
47
  ## 📋 OCPP 1.6 Compliance Status
@@ -157,17 +158,25 @@ and the [Security Guide](docs/security.md).
157
158
 
158
159
  ### Monitor Charge Point Status
159
160
 
161
+ `ChargePoint#status` is the whole-station status (from connector-0
162
+ StatusNotifications: Available/Unavailable/Faulted) — it says nothing about
163
+ any individual connector once a station has more than one.
164
+
160
165
  ```ruby
161
166
  # Query charge points
162
167
  connected_cps = Ocpp::Rails::ChargePoint.connected
163
168
  available_cps = Ocpp::Rails::ChargePoint.available
164
- charging_cps = Ocpp::Rails::ChargePoint.charging
169
+ charging_cps = Ocpp::Rails::ChargePoint.charging # any active session, any connector
165
170
 
166
171
  # Check specific charge point
167
172
  cp = Ocpp::Rails::ChargePoint.find_by(identifier: "CP001")
168
173
  cp.connected? # => true/false
169
- cp.status # => "Available", "Charging", etc.
174
+ cp.status # => whole-station status: "Available", "Unavailable", "Faulted"
170
175
  cp.last_heartbeat_at
176
+
177
+ # Per-connector status (independent of any other connector on the same station)
178
+ cp.connector_status(1) # => "Available", "Charging", "SuspendedEV", ...
179
+ cp.connector_charging?(1) # => true/false, derived from active sessions
171
180
  ```
172
181
 
173
182
  ### Monitor Active Sessions
@@ -269,8 +278,12 @@ For complete implementation examples, see the [Remote Charging Guide](docs/remot
269
278
 
270
279
  - **`Ocpp::Rails::ChargePoint`** - Represents a physical charging station
271
280
  - Tracks connection status, firmware version, vendor info
272
- - Has many charging sessions and meter values
273
- - Provides status scopes (connected, available, charging)
281
+ - Has many charging sessions, connector statuses, and meter values
282
+ - Provides status scopes (connected, available, charging) and per-connector reads (`connector_status`, `connector_charging?`)
283
+
284
+ - **`Ocpp::Rails::ConnectorStatus`** - Last reported status for one connector
285
+ - One row per `(charge_point, connector_id)`, written by StatusNotification
286
+ - Independent of whole-station status and of any other connector's activity
274
287
 
275
288
  - **`Ocpp::Rails::ChargingSession`** - Represents a charging transaction
276
289
  - Manages session lifecycle (active/completed)
@@ -0,0 +1,25 @@
1
+ module Ocpp
2
+ module Rails
3
+ class SessionAsyncHookJob < ApplicationJob
4
+ queue_as :ocpp_hooks
5
+
6
+ retry_on StandardError, wait: :polynomially_longer, attempts: 3
7
+
8
+ def perform(charging_session_id, event, hook_class_name)
9
+ charging_session = Ocpp::Rails::ChargingSession.find_by(id: charging_session_id)
10
+
11
+ unless charging_session
12
+ ::Rails.logger.warn("ChargingSession #{charging_session_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(charging_session, event)
19
+ rescue => error
20
+ ::Rails.logger.error("SessionAsyncHook #{hook_class_name} failed for ChargingSession #{charging_session_id} (#{event}): #{error.message}")
21
+ raise
22
+ end
23
+ end
24
+ end
25
+ end
@@ -4,6 +4,7 @@ module Ocpp
4
4
  self.table_name = "ocpp_charge_points"
5
5
 
6
6
  has_many :charging_sessions, dependent: :destroy, class_name: "Ocpp::Rails::ChargingSession"
7
+ has_many :connector_statuses, dependent: :destroy, class_name: "Ocpp::Rails::ConnectorStatus"
7
8
  has_many :meter_values, dependent: :destroy, class_name: "Ocpp::Rails::MeterValue"
8
9
  has_many :messages, dependent: :destroy, class_name: "Ocpp::Rails::Message"
9
10
  has_many :state_changes, dependent: :destroy, class_name: "Ocpp::Rails::StateChange"
@@ -14,7 +15,9 @@ module Ocpp
14
15
 
15
16
  scope :connected, -> { where(connected: true) }
16
17
  scope :available, -> { where(status: "Available") }
17
- scope :charging, -> { where(status: "Charging") }
18
+ # Charging is a fact about sessions, not about ChargePoint#status
19
+ # (which only ever holds connector-0 values: Available/Unavailable/Faulted).
20
+ scope :charging, -> { joins(:charging_sessions).merge(Ocpp::Rails::ChargingSession.active).distinct }
18
21
 
19
22
  # Stores the station's Basic Auth password as a SHA-256 digest.
20
23
  # OCPP-J passwords are high-entropy machine credentials (the spec
@@ -64,6 +67,23 @@ module Ocpp
64
67
  def available?
65
68
  status == "Available" && connected?
66
69
  end
70
+
71
+ # Last status the station reported for this connector via
72
+ # StatusNotification; nil until the connector has reported once.
73
+ def connector_status(connector_id)
74
+ connector_statuses.find_by(connector_id: connector_id)&.status
75
+ end
76
+
77
+ def connector_error_code(connector_id)
78
+ connector_statuses.find_by(connector_id: connector_id)&.error_code
79
+ end
80
+
81
+ # Whether a transaction is running on the connector. Authoritative by
82
+ # definition, unlike connector_status which is only as fresh as the
83
+ # station's last StatusNotification.
84
+ def connector_charging?(connector_id)
85
+ charging_sessions.active.where(connector_id: connector_id).exists?
86
+ end
67
87
  end
68
88
  end
69
89
  end
@@ -0,0 +1,16 @@
1
+ module Ocpp
2
+ module Rails
3
+ class ConnectorStatus < ApplicationRecord
4
+ self.table_name = "ocpp_connector_statuses"
5
+
6
+ belongs_to :charge_point, class_name: "Ocpp::Rails::ChargePoint"
7
+
8
+ # Connector 0 (the charge point main controller) is tracked on
9
+ # ChargePoint#status, never here.
10
+ validates :connector_id, presence: true,
11
+ numericality: { greater_than_or_equal_to: 1, only_integer: true },
12
+ uniqueness: { scope: :charge_point_id }
13
+ validates :status, presence: true
14
+ end
15
+ end
16
+ end
@@ -51,8 +51,12 @@ module Ocpp
51
51
 
52
52
  ::Rails.logger.info("[OCPP] StartTransaction from #{@charge_point.identifier}: Connector #{@payload['connectorId']}, Transaction ID: #{session.transaction_id}")
53
53
 
54
- # Update charge point status
55
- @charge_point.update(status: "Charging")
54
+ # Connector status is owned by StatusNotification (OCPP 1.6); a
55
+ # transaction starting says nothing about ChargePoint#status.
56
+
57
+ # Duplicate/replayed StartTransactions resume the open session
58
+ # above and intentionally do not re-fire this hook.
59
+ Ocpp::Rails::SessionHookManager.execute_hooks(session, "started")
56
60
 
57
61
  # Broadcast session started event for real-time UI updates
58
62
  broadcast_session_started(session)
@@ -43,13 +43,9 @@ module Ocpp
43
43
  private
44
44
 
45
45
  def update_connector_status(connector_id, status, error_code)
46
- metadata = @charge_point.metadata || {}
47
- old_status = metadata["connector_#{connector_id}_status"]
48
-
49
- metadata["connector_#{connector_id}_status"] = status
50
- metadata["connector_#{connector_id}_error_code"] = error_code if error_code
51
- metadata["connector_#{connector_id}_updated_at"] = Time.current.iso8601
52
- @charge_point.update(metadata: metadata)
46
+ record = @charge_point.connector_statuses.find_or_initialize_by(connector_id: connector_id)
47
+ old_status = record.status
48
+ record.update!(status: status, error_code: error_code)
53
49
 
54
50
  # Log state change if status actually changed
55
51
  log_status_change(connector_id, old_status, status, {
@@ -34,10 +34,13 @@ module Ocpp
34
34
  process_transaction_data(session, @payload["transactionData"])
35
35
  end
36
36
 
37
- # Update charge point status if no other active sessions
38
- if @charge_point.charging_sessions.active.empty?
39
- @charge_point.update(status: "Available")
40
- end
37
+ # Connector status is owned by StatusNotification (OCPP 1.6); the
38
+ # station reports Finishing/Available itself after the transaction.
39
+
40
+ # Fired after transactionData so hooks see the Transaction.End
41
+ # meter values. Stations recovering from an offline period may
42
+ # still deliver queued MeterValues after this point.
43
+ Ocpp::Rails::SessionHookManager.execute_hooks(session, "stopped")
41
44
 
42
45
  # Broadcast session stopped event for real-time UI updates
43
46
  broadcast_session_stopped(session)
@@ -0,0 +1,37 @@
1
+ module Ocpp
2
+ module Rails
3
+ # Moves per-connector status from the pre-0.3.0 metadata convention
4
+ # (connector_<n>_status / _error_code / _updated_at keys on
5
+ # ChargePoint#metadata) into the ocpp_connector_statuses table.
6
+ # Idempotent; rows the station has written since the upgrade win over
7
+ # legacy metadata.
8
+ module ConnectorStatusBackfill
9
+ LEGACY_KEY = /\Aconnector_(\d+)_(status|error_code|updated_at)\z/
10
+
11
+ def self.run
12
+ Ocpp::Rails::ChargePoint.find_each do |charge_point|
13
+ metadata = charge_point.metadata || {}
14
+ legacy = metadata.select { |key, _| key.to_s.match?(LEGACY_KEY) }
15
+ next if legacy.empty?
16
+
17
+ connector_ids(legacy).each do |connector_id|
18
+ status = legacy["connector_#{connector_id}_status"]
19
+ next unless status
20
+
21
+ record = charge_point.connector_statuses.find_or_initialize_by(connector_id: connector_id)
22
+ next if record.persisted?
23
+
24
+ record.update!(status: status, error_code: legacy["connector_#{connector_id}_error_code"])
25
+ end
26
+
27
+ charge_point.update!(metadata: metadata.except(*legacy.keys))
28
+ end
29
+ end
30
+
31
+ def self.connector_ids(legacy)
32
+ legacy.keys.filter_map { |key| key.to_s[LEGACY_KEY, 1]&.to_i }.uniq
33
+ end
34
+ private_class_method :connector_ids
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,28 @@
1
+ module Ocpp
2
+ module Rails
3
+ module SessionHookManager
4
+ EVENTS = [ "started", "stopped" ].freeze
5
+
6
+ def self.execute_hooks(charging_session, event)
7
+ hooks = Ocpp::Rails.configuration.session_hooks
8
+
9
+ hooks.each do |hook|
10
+ begin
11
+ if hook.respond_to?(:async?) && hook.async?
12
+ # Enqueue async hook job
13
+ Ocpp::Rails::SessionAsyncHookJob.perform_later(charging_session.id, event, hook.class.name)
14
+ else
15
+ # Execute synchronously
16
+ hook.call(charging_session, event)
17
+ end
18
+ rescue => error
19
+ ::Rails.logger.error("SessionHook #{hook.class.name} failed: #{error.message}")
20
+ ::Rails.logger.error(error.backtrace.join("\n"))
21
+ end
22
+ end
23
+
24
+ true
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,13 @@
1
+ class CreateOcppConnectorStatuses < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :ocpp_connector_statuses do |t|
4
+ t.references :charge_point, null: false, foreign_key: { to_table: :ocpp_charge_points }
5
+ t.integer :connector_id, null: false
6
+ t.string :status, null: false
7
+ t.string :error_code
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :ocpp_connector_statuses, [ :charge_point_id, :connector_id ], unique: true
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ class BackfillOcppConnectorStatuses < ActiveRecord::Migration[8.0]
2
+ def up
3
+ Ocpp::Rails::ConnectorStatusBackfill.run
4
+ end
5
+
6
+ def down
7
+ # Data-only migration; the legacy metadata keys are not restored.
8
+ end
9
+ end
@@ -23,6 +23,8 @@ module Ocpp
23
23
  migration_template "create_ocpp_meter_values.rb", "db/migrate/create_ocpp_meter_values.rb"
24
24
  sleep 1
25
25
  migration_template "create_ocpp_messages.rb", "db/migrate/create_ocpp_messages.rb"
26
+ sleep 1
27
+ migration_template "create_ocpp_connector_statuses.rb", "db/migrate/create_ocpp_connector_statuses.rb"
26
28
  end
27
29
 
28
30
  def mount_engine
@@ -0,0 +1,13 @@
1
+ class CreateOcppConnectorStatuses < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :ocpp_connector_statuses do |t|
4
+ t.references :charge_point, null: false, foreign_key: { to_table: :ocpp_charge_points }
5
+ t.integer :connector_id, null: false
6
+ t.string :status, null: false
7
+ t.string :error_code
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :ocpp_connector_statuses, [ :charge_point_id, :connector_id ], unique: true
12
+ end
13
+ end
@@ -10,4 +10,11 @@ Ocpp::Rails.setup do |config|
10
10
 
11
11
  # Connection timeout in seconds
12
12
  config.connection_timeout = 30
13
+
14
+ # Session lifecycle hooks: objects responding to
15
+ # call(charging_session, event) with event "started" or "stopped".
16
+ # Define async? => true on a hook to run it via ActiveJob instead of
17
+ # inline. Example:
18
+ #
19
+ # config.register_session_hook(MySessionHook.new)
13
20
  end
@@ -1,5 +1,5 @@
1
1
  module Ocpp
2
2
  module Rails
3
- VERSION = "0.2.4"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
data/lib/ocpp/rails.rb CHANGED
@@ -36,7 +36,7 @@ module Ocpp
36
36
  attr_accessor :ocpp_version, :supported_versions, :heartbeat_interval, :connection_timeout,
37
37
  :state_change_hooks, :state_change_retention_days, :state_change_cleanup_enabled,
38
38
  :authorization_hooks, :authorization_retention_days, :authorization_cleanup_enabled,
39
- :implausible_energy_jump_wh, :authentication_mode,
39
+ :session_hooks, :implausible_energy_jump_wh, :authentication_mode,
40
40
  :max_messages_per_minute, :max_connection_attempts_per_minute
41
41
 
42
42
  def initialize
@@ -47,6 +47,7 @@ module Ocpp
47
47
  @connection_timeout = 30
48
48
  @state_change_hooks = []
49
49
  @authorization_hooks = []
50
+ @session_hooks = []
50
51
  @state_change_retention_days = 30
51
52
  @state_change_cleanup_enabled = true
52
53
  @authorization_retention_days = 30
@@ -77,6 +78,13 @@ module Ocpp
77
78
  end
78
79
  @authorization_hooks << hook
79
80
  end
81
+
82
+ def register_session_hook(hook)
83
+ unless hook.respond_to?(:call)
84
+ raise ArgumentError, "Hook must respond to :call method"
85
+ end
86
+ @session_hooks << hook
87
+ end
80
88
  end
81
89
  end
82
90
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ocpp-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jakob Sommerhuber
@@ -119,12 +119,14 @@ files:
119
119
  - app/jobs/ocpp/rails/remote_start_transaction_job.rb
120
120
  - app/jobs/ocpp/rails/remote_stop_transaction_job.rb
121
121
  - app/jobs/ocpp/rails/reset_job.rb
122
+ - app/jobs/ocpp/rails/session_async_hook_job.rb
122
123
  - app/jobs/ocpp/rails/unlock_connector_job.rb
123
124
  - app/mailers/ocpp/rails/application_mailer.rb
124
125
  - app/models/ocpp/rails/application_record.rb
125
126
  - app/models/ocpp/rails/authorization.rb
126
127
  - app/models/ocpp/rails/charge_point.rb
127
128
  - app/models/ocpp/rails/charging_session.rb
129
+ - app/models/ocpp/rails/connector_status.rb
128
130
  - app/models/ocpp/rails/message.rb
129
131
  - app/models/ocpp/rails/meter_value.rb
130
132
  - app/models/ocpp/rails/state_change.rb
@@ -136,10 +138,12 @@ files:
136
138
  - app/services/ocpp/rails/actions/status_notification_handler.rb
137
139
  - app/services/ocpp/rails/actions/stop_transaction_handler.rb
138
140
  - app/services/ocpp/rails/authorization_hook_manager.rb
141
+ - app/services/ocpp/rails/connector_status_backfill.rb
139
142
  - app/services/ocpp/rails/message_handler.rb
140
143
  - app/services/ocpp/rails/meter_anomaly_detector.rb
141
144
  - app/services/ocpp/rails/protocol.rb
142
145
  - app/services/ocpp/rails/rate_limiter.rb
146
+ - app/services/ocpp/rails/session_hook_manager.rb
143
147
  - app/services/ocpp/rails/state_change_hook_manager.rb
144
148
  - app/services/ocpp/rails/station_authenticator.rb
145
149
  - app/services/ocpp/rails/timestamp_parser.rb
@@ -153,10 +157,13 @@ files:
153
157
  - db/migrate/20260707000002_add_active_session_guard_to_ocpp_charging_sessions.rb
154
158
  - db/migrate/20260707000003_add_anomaly_flags_to_ocpp_meter_values.rb
155
159
  - db/migrate/20260707000004_add_auth_password_digest_to_ocpp_charge_points.rb
160
+ - db/migrate/20260711000000_create_ocpp_connector_statuses.rb
161
+ - db/migrate/20260711000001_backfill_ocpp_connector_statuses.rb
156
162
  - lib/generators/ocpp/rails/install_generator.rb
157
163
  - lib/generators/ocpp/rails/templates/README
158
164
  - lib/generators/ocpp/rails/templates/create_ocpp_charge_points.rb
159
165
  - lib/generators/ocpp/rails/templates/create_ocpp_charging_sessions.rb
166
+ - lib/generators/ocpp/rails/templates/create_ocpp_connector_statuses.rb
160
167
  - lib/generators/ocpp/rails/templates/create_ocpp_messages.rb
161
168
  - lib/generators/ocpp/rails/templates/create_ocpp_meter_values.rb
162
169
  - lib/generators/ocpp/rails/templates/ocpp_rails.rb