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
@@ -0,0 +1,42 @@
1
+ module Ocpp
2
+ module Rails
3
+ class ChangeAvailabilityJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ # connector_id/type are the OCPP ChangeAvailability.req fields; type is
7
+ # "Operative" or "Inoperative", and connectorId 0 targets the whole charge
8
+ # point rather than a single connector.
9
+ def perform(charge_point_id, connector_id, type)
10
+ charge_point = ChargePoint.find(charge_point_id)
11
+ message_id = SecureRandom.uuid
12
+
13
+ payload = {
14
+ connectorId: connector_id.to_i,
15
+ type: type
16
+ }
17
+
18
+ message = Protocol.build_call(message_id, "ChangeAvailability", payload)
19
+
20
+ Message.create!(
21
+ charge_point: charge_point,
22
+ message_id: message_id,
23
+ direction: "outbound",
24
+ action: "ChangeAvailability",
25
+ message_type: "CALL",
26
+ payload: payload,
27
+ status: "pending"
28
+ )
29
+
30
+ send_to_charge_point(charge_point, message)
31
+ end
32
+
33
+ private
34
+
35
+ def send_to_charge_point(charge_point, message)
36
+ # stream_for in ChargePointChannel relays this straight down the
37
+ # station's WebSocket, the same path CALLRESULTs already use.
38
+ ChargePointChannel.broadcast_to(charge_point, { message: message })
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,41 @@
1
+ module Ocpp
2
+ module Rails
3
+ class ChangeConfigurationJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ # key/value are the OCPP ChangeConfiguration.req fields; value is always a
7
+ # string, even for numeric configuration keys.
8
+ def perform(charge_point_id, key, value)
9
+ charge_point = ChargePoint.find(charge_point_id)
10
+ message_id = SecureRandom.uuid
11
+
12
+ payload = {
13
+ key: key,
14
+ value: value
15
+ }
16
+
17
+ message = Protocol.build_call(message_id, "ChangeConfiguration", payload)
18
+
19
+ Message.create!(
20
+ charge_point: charge_point,
21
+ message_id: message_id,
22
+ direction: "outbound",
23
+ action: "ChangeConfiguration",
24
+ message_type: "CALL",
25
+ payload: payload,
26
+ status: "pending"
27
+ )
28
+
29
+ send_to_charge_point(charge_point, message)
30
+ end
31
+
32
+ private
33
+
34
+ def send_to_charge_point(charge_point, message)
35
+ # stream_for in ChargePointChannel relays this straight down the
36
+ # station's WebSocket, the same path CALLRESULTs already use.
37
+ ChargePointChannel.broadcast_to(charge_point, { message: message })
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,28 @@
1
+ module Ocpp
2
+ module Rails
3
+ class CleanupAuthorizationsJob < ApplicationJob
4
+ queue_as :ocpp_maintenance
5
+
6
+ def perform
7
+ unless Ocpp::Rails.configuration.authorization_cleanup_enabled
8
+ ::Rails.logger.info("Authorization cleanup disabled, skipping")
9
+ return
10
+ end
11
+
12
+ begin
13
+ retention_days = Ocpp::Rails.configuration.authorization_retention_days
14
+ deleted_count = Ocpp::Rails::Authorization.older_than(retention_days).delete_all
15
+
16
+ ::Rails.logger.info("Deleted #{deleted_count} Authorization records older than #{retention_days} days")
17
+ rescue => error
18
+ ::Rails.logger.error("Authorization cleanup failed: #{error.message}")
19
+ ensure
20
+ # Re-enqueue for next run (24 hours later) only if cleanup is enabled
21
+ if Ocpp::Rails.configuration.authorization_cleanup_enabled
22
+ self.class.set(wait: 24.hours).perform_later
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ module Ocpp
2
+ module Rails
3
+ class CleanupStateChangesJob < ApplicationJob
4
+ queue_as :ocpp_maintenance
5
+
6
+ def perform
7
+ unless Ocpp::Rails.configuration.state_change_cleanup_enabled
8
+ ::Rails.logger.info("StateChange cleanup disabled, skipping")
9
+ return
10
+ end
11
+
12
+ begin
13
+ retention_days = Ocpp::Rails.configuration.state_change_retention_days
14
+ deleted_count = Ocpp::Rails::StateChange.older_than(retention_days).delete_all
15
+
16
+ ::Rails.logger.info("Deleted #{deleted_count} StateChange records older than #{retention_days} days")
17
+ rescue => error
18
+ ::Rails.logger.error("StateChange cleanup failed: #{error.message}")
19
+ ensure
20
+ # Re-enqueue for next run (24 hours later) only if cleanup is enabled
21
+ if Ocpp::Rails.configuration.state_change_cleanup_enabled
22
+ self.class.set(wait: 24.hours).perform_later
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,37 @@
1
+ module Ocpp
2
+ module Rails
3
+ class ClearCacheJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform(charge_point_id)
7
+ charge_point = ChargePoint.find(charge_point_id)
8
+ message_id = SecureRandom.uuid
9
+
10
+ # ClearCache.req carries no fields.
11
+ payload = {}
12
+
13
+ message = Protocol.build_call(message_id, "ClearCache", payload)
14
+
15
+ Message.create!(
16
+ charge_point: charge_point,
17
+ message_id: message_id,
18
+ direction: "outbound",
19
+ action: "ClearCache",
20
+ message_type: "CALL",
21
+ payload: payload,
22
+ status: "pending"
23
+ )
24
+
25
+ send_to_charge_point(charge_point, message)
26
+ end
27
+
28
+ private
29
+
30
+ def send_to_charge_point(charge_point, message)
31
+ # stream_for in ChargePointChannel relays this straight down the
32
+ # station's WebSocket, the same path CALLRESULTs already use.
33
+ ChargePointChannel.broadcast_to(charge_point, { message: message })
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,38 @@
1
+ module Ocpp
2
+ module Rails
3
+ class GetConfigurationJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ # keys is the OCPP GetConfiguration.req optional key array; when blank the
7
+ # station returns every configuration key.
8
+ def perform(charge_point_id, keys = [])
9
+ charge_point = ChargePoint.find(charge_point_id)
10
+ message_id = SecureRandom.uuid
11
+
12
+ payload = keys.blank? ? {} : { key: Array(keys) }
13
+
14
+ message = Protocol.build_call(message_id, "GetConfiguration", payload)
15
+
16
+ Message.create!(
17
+ charge_point: charge_point,
18
+ message_id: message_id,
19
+ direction: "outbound",
20
+ action: "GetConfiguration",
21
+ message_type: "CALL",
22
+ payload: payload,
23
+ status: "pending"
24
+ )
25
+
26
+ send_to_charge_point(charge_point, message)
27
+ end
28
+
29
+ private
30
+
31
+ def send_to_charge_point(charge_point, message)
32
+ # stream_for in ChargePointChannel relays this straight down the
33
+ # station's WebSocket, the same path CALLRESULTs already use.
34
+ ChargePointChannel.broadcast_to(charge_point, { message: message })
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,39 @@
1
+ module Ocpp
2
+ module Rails
3
+ class RemoteStartTransactionJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform(charge_point_id, connector_id, id_tag)
7
+ charge_point = ChargePoint.find(charge_point_id)
8
+ message_id = SecureRandom.uuid
9
+
10
+ payload = {
11
+ connectorId: connector_id.to_i,
12
+ idTag: id_tag
13
+ }
14
+
15
+ message = Protocol.build_call(message_id, "RemoteStartTransaction", payload)
16
+
17
+ Message.create!(
18
+ charge_point: charge_point,
19
+ message_id: message_id,
20
+ direction: "outbound",
21
+ action: "RemoteStartTransaction",
22
+ message_type: "CALL",
23
+ payload: payload,
24
+ status: "pending"
25
+ )
26
+
27
+ send_to_charge_point(charge_point, message)
28
+ end
29
+
30
+ private
31
+
32
+ def send_to_charge_point(charge_point, message)
33
+ # stream_for in ChargePointChannel relays this straight down the
34
+ # station's WebSocket, the same path CALLRESULTs already use.
35
+ ChargePointChannel.broadcast_to(charge_point, { message: message })
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,38 @@
1
+ module Ocpp
2
+ module Rails
3
+ class RemoteStopTransactionJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform(charge_point_id, transaction_id)
7
+ charge_point = ChargePoint.find(charge_point_id)
8
+ message_id = SecureRandom.uuid
9
+
10
+ payload = {
11
+ transactionId: transaction_id
12
+ }
13
+
14
+ message = Protocol.build_call(message_id, "RemoteStopTransaction", payload)
15
+
16
+ Message.create!(
17
+ charge_point: charge_point,
18
+ message_id: message_id,
19
+ direction: "outbound",
20
+ action: "RemoteStopTransaction",
21
+ message_type: "CALL",
22
+ payload: payload,
23
+ status: "pending"
24
+ )
25
+
26
+ send_to_charge_point(charge_point, message)
27
+ end
28
+
29
+ private
30
+
31
+ def send_to_charge_point(charge_point, message)
32
+ # stream_for in ChargePointChannel relays this straight down the
33
+ # station's WebSocket, the same path CALLRESULTs already use.
34
+ ChargePointChannel.broadcast_to(charge_point, { message: message })
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,39 @@
1
+ module Ocpp
2
+ module Rails
3
+ class ResetJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ # type is the OCPP Reset.req ResetType: "Hard" or "Soft".
7
+ def perform(charge_point_id, type)
8
+ charge_point = ChargePoint.find(charge_point_id)
9
+ message_id = SecureRandom.uuid
10
+
11
+ payload = {
12
+ type: type
13
+ }
14
+
15
+ message = Protocol.build_call(message_id, "Reset", payload)
16
+
17
+ Message.create!(
18
+ charge_point: charge_point,
19
+ message_id: message_id,
20
+ direction: "outbound",
21
+ action: "Reset",
22
+ message_type: "CALL",
23
+ payload: payload,
24
+ status: "pending"
25
+ )
26
+
27
+ send_to_charge_point(charge_point, message)
28
+ end
29
+
30
+ private
31
+
32
+ def send_to_charge_point(charge_point, message)
33
+ # stream_for in ChargePointChannel relays this straight down the
34
+ # station's WebSocket, the same path CALLRESULTs already use.
35
+ ChargePointChannel.broadcast_to(charge_point, { message: message })
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,38 @@
1
+ module Ocpp
2
+ module Rails
3
+ class UnlockConnectorJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform(charge_point_id, connector_id)
7
+ charge_point = ChargePoint.find(charge_point_id)
8
+ message_id = SecureRandom.uuid
9
+
10
+ payload = {
11
+ connectorId: connector_id.to_i
12
+ }
13
+
14
+ message = Protocol.build_call(message_id, "UnlockConnector", payload)
15
+
16
+ Message.create!(
17
+ charge_point: charge_point,
18
+ message_id: message_id,
19
+ direction: "outbound",
20
+ action: "UnlockConnector",
21
+ message_type: "CALL",
22
+ payload: payload,
23
+ status: "pending"
24
+ )
25
+
26
+ send_to_charge_point(charge_point, message)
27
+ end
28
+
29
+ private
30
+
31
+ def send_to_charge_point(charge_point, message)
32
+ # stream_for in ChargePointChannel relays this straight down the
33
+ # station's WebSocket, the same path CALLRESULTs already use.
34
+ ChargePointChannel.broadcast_to(charge_point, { message: message })
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,8 @@
1
+ module Ocpp
2
+ module Rails
3
+ class ApplicationMailer < ActionMailer::Base
4
+ default from: "from@example.com"
5
+ layout "mailer"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ module Ocpp
2
+ module Rails
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,26 @@
1
+ module Ocpp
2
+ module Rails
3
+ class Authorization < ApplicationRecord
4
+ self.table_name = "ocpp_authorizations"
5
+
6
+ belongs_to :charge_point, class_name: "Ocpp::Rails::ChargePoint"
7
+
8
+ validates :status, presence: true, inclusion: { in: [ "Accepted", "Blocked", "Expired", "Invalid", "ConcurrentTx" ] }
9
+ validates :id_tag, presence: true
10
+ validates :charge_point, presence: true
11
+
12
+ scope :accepted, -> { where(status: "Accepted") }
13
+ scope :rejected, -> { where.not(status: "Accepted") }
14
+ scope :for_id_tag, ->(tag) { where(id_tag: tag) }
15
+ scope :older_than, ->(days) { where("created_at < ?", days.days.ago) }
16
+
17
+ after_commit :trigger_async_hooks, on: :create
18
+
19
+ private
20
+
21
+ def trigger_async_hooks
22
+ Ocpp::Rails::AuthorizationHookManager.execute_async_hooks(self)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,69 @@
1
+ module Ocpp
2
+ module Rails
3
+ class ChargePoint < ApplicationRecord
4
+ self.table_name = "ocpp_charge_points"
5
+
6
+ has_many :charging_sessions, dependent: :destroy, class_name: "Ocpp::Rails::ChargingSession"
7
+ has_many :meter_values, dependent: :destroy, class_name: "Ocpp::Rails::MeterValue"
8
+ has_many :messages, dependent: :destroy, class_name: "Ocpp::Rails::Message"
9
+ has_many :state_changes, dependent: :destroy, class_name: "Ocpp::Rails::StateChange"
10
+ has_many :authorizations, dependent: :destroy, class_name: "Ocpp::Rails::Authorization"
11
+
12
+ validates :identifier, presence: true, uniqueness: true
13
+ validates :ocpp_protocol, inclusion: { in: Ocpp::Rails.supported_versions }
14
+
15
+ scope :connected, -> { where(connected: true) }
16
+ scope :available, -> { where(status: "Available") }
17
+ scope :charging, -> { where(status: "Charging") }
18
+
19
+ # Stores the station's Basic Auth password as a SHA-256 digest.
20
+ # OCPP-J passwords are high-entropy machine credentials (the spec
21
+ # mandates 16-40 random bytes), so a fast unsalted hash is appropriate,
22
+ # like for API tokens.
23
+ def auth_password=(password)
24
+ self.auth_password_digest = password.nil? ? nil : Digest::SHA256.hexdigest(password)
25
+ end
26
+
27
+ def authenticate_password?(password)
28
+ return false if auth_password_digest.blank? || password.blank?
29
+
30
+ ActiveSupport::SecurityUtils.fixed_length_secure_compare(
31
+ Digest::SHA256.hexdigest(password),
32
+ auth_password_digest
33
+ )
34
+ end
35
+
36
+ def heartbeat!
37
+ old_connected = connected
38
+ update(last_heartbeat_at: Time.current, connected: true)
39
+
40
+ # Log connection state change only if reconnecting (false -> true)
41
+ if old_connected == false
42
+ begin
43
+ state_changes.create!(
44
+ change_type: "connection",
45
+ connector_id: nil,
46
+ old_value: "false",
47
+ new_value: "true",
48
+ metadata: { source: "heartbeat" }
49
+ )
50
+ rescue => error
51
+ ::Rails.logger.error("Failed to log state change: #{error.message}")
52
+ end
53
+ end
54
+ end
55
+
56
+ def disconnect!
57
+ update(connected: false)
58
+ end
59
+
60
+ def current_session
61
+ charging_sessions.where(stopped_at: nil).order(started_at: :desc).first
62
+ end
63
+
64
+ def available?
65
+ status == "Available" && connected?
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,72 @@
1
+ module Ocpp
2
+ module Rails
3
+ class ChargingSession < ApplicationRecord
4
+ self.table_name = "ocpp_charging_sessions"
5
+
6
+ belongs_to :charge_point, class_name: "Ocpp::Rails::ChargePoint"
7
+ has_many :meter_values, dependent: :destroy, class_name: "Ocpp::Rails::MeterValue"
8
+
9
+ validates :connector_id, presence: true
10
+ validates :transaction_id, uniqueness: true, allow_nil: true
11
+
12
+ scope :active, -> { where(stopped_at: nil) }
13
+ scope :completed, -> { where.not(stopped_at: nil) }
14
+
15
+ before_create :generate_transaction_id
16
+
17
+ def active?
18
+ stopped_at.nil?
19
+ end
20
+
21
+ def stop!(reason: "Local", meter_value: nil)
22
+ energy = calculate_energy_consumed(meter_value)
23
+ new_metadata = metadata || {}
24
+
25
+ if energy.nil?
26
+ ::Rails.logger.warn("[OCPP] Session #{transaction_id}: stop meter value below meterStart (rollover or meter swap?); flagging instead of recording negative energy")
27
+ new_metadata = new_metadata.merge("energy_anomaly" => "stop_below_start")
28
+ end
29
+
30
+ update(
31
+ stopped_at: Time.current,
32
+ stop_meter_value: meter_value,
33
+ stop_reason: reason,
34
+ duration_seconds: calculate_duration,
35
+ energy_consumed: energy,
36
+ status: "Completed",
37
+ metadata: new_metadata
38
+ )
39
+ end
40
+
41
+ def calculate_duration
42
+ return 0 unless started_at
43
+ ((stopped_at || Time.current) - started_at).to_i
44
+ end
45
+
46
+ # Returns nil (never a negative number) when the stop value is below
47
+ # meterStart, e.g. after a register rollover or meter swap.
48
+ def calculate_energy_consumed(stop_value = nil)
49
+ return 0 unless start_meter_value
50
+ stop_val = stop_value || stop_meter_value || start_meter_value
51
+ return nil if stop_val < start_meter_value
52
+ stop_val - start_meter_value
53
+ end
54
+
55
+ # OCPP 1.6 transactionId is a signed 32-bit integer
56
+ MAX_TRANSACTION_ID = (2**31) - 1
57
+
58
+ def self.generate_wire_transaction_id
59
+ loop do
60
+ candidate = SecureRandom.random_number(MAX_TRANSACTION_ID) + 1
61
+ break candidate unless exists?(transaction_id: candidate)
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def generate_transaction_id
68
+ self.transaction_id ||= self.class.generate_wire_transaction_id
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,17 @@
1
+ module Ocpp
2
+ module Rails
3
+ class Message < ApplicationRecord
4
+ self.table_name = "ocpp_messages"
5
+
6
+ belongs_to :charge_point, class_name: "Ocpp::Rails::ChargePoint"
7
+
8
+ validates :message_id, presence: true
9
+ validates :direction, inclusion: { in: %w[inbound outbound] }
10
+ validates :message_type, inclusion: { in: %w[CALL CALLRESULT CALLERROR] }
11
+
12
+ scope :inbound, -> { where(direction: "inbound") }
13
+ scope :outbound, -> { where(direction: "outbound") }
14
+ scope :recent, -> { order(created_at: :desc) }
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,24 @@
1
+ module Ocpp
2
+ module Rails
3
+ class MeterValue < ApplicationRecord
4
+ self.table_name = "ocpp_meter_values"
5
+
6
+ belongs_to :charging_session, optional: true, class_name: "Ocpp::Rails::ChargingSession"
7
+ belongs_to :charge_point, class_name: "Ocpp::Rails::ChargePoint"
8
+
9
+ validates :measurand, presence: true
10
+
11
+ scope :energy, -> { where(measurand: "Energy.Active.Import.Register") }
12
+ scope :power, -> { where(measurand: "Power.Active.Import") }
13
+ scope :current, -> { where(measurand: "Current.Import") }
14
+ scope :voltage, -> { where(measurand: "Voltage") }
15
+ scope :recent, -> { order(timestamp: :desc) }
16
+ scope :flagged, -> { where(flagged: true) }
17
+
18
+ def value_in_wh
19
+ return nil if value.nil?
20
+ unit == "kWh" ? value * 1000 : value
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,26 @@
1
+ module Ocpp
2
+ module Rails
3
+ class StateChange < ApplicationRecord
4
+ self.table_name = "ocpp_state_changes"
5
+
6
+ belongs_to :charge_point, class_name: "Ocpp::Rails::ChargePoint"
7
+
8
+ validates :change_type, presence: true, inclusion: { in: [ "status", "connection" ] }
9
+ validates :new_value, presence: true
10
+ validates :connector_id, numericality: { greater_than_or_equal_to: 0, only_integer: true }, allow_nil: true
11
+
12
+ scope :status_changes, -> { where(change_type: "status") }
13
+ scope :connection_changes, -> { where(change_type: "connection") }
14
+ scope :for_connector, ->(connector_id) { where(connector_id: connector_id) }
15
+ scope :older_than, ->(days) { where("created_at < ?", days.days.ago) }
16
+
17
+ after_commit :trigger_hooks, on: :create
18
+
19
+ private
20
+
21
+ def trigger_hooks
22
+ Ocpp::Rails::StateChangeHookManager.execute_hooks(self)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,47 @@
1
+ module Ocpp
2
+ module Rails
3
+ module Actions
4
+ class AuthorizeHandler
5
+ def initialize(charge_point, message_id, payload)
6
+ @charge_point = charge_point
7
+ @message_id = message_id
8
+ @payload = payload
9
+ end
10
+
11
+ def call
12
+ id_tag = @payload["idTag"]
13
+
14
+ ::Rails.logger.info("[OCPP] Authorize request from #{@charge_point.identifier} for idTag: #{id_tag}")
15
+
16
+ # Execute authorization hooks to determine access decision
17
+ result = Ocpp::Rails::AuthorizationHookManager.execute_hooks(@charge_point.id, id_tag)
18
+
19
+ # Persist authorization record for audit trail
20
+ begin
21
+ Ocpp::Rails::Authorization.create!(
22
+ charge_point_id: @charge_point.id,
23
+ id_tag: id_tag,
24
+ status: result[:status],
25
+ expiry_date: result[:expiry_date]
26
+ )
27
+ rescue => error
28
+ ::Rails.logger.error("[OCPP] Failed to persist Authorization record: #{error.message}")
29
+ # Continue - authorization decision already made, persistence failure shouldn't block response
30
+ end
31
+
32
+ # Build OCPP response
33
+ id_tag_info = { "status" => result[:status] }
34
+
35
+ # Only include expiryDate if status is Accepted and expiry_date is present
36
+ if result[:status] == "Accepted" && result[:expiry_date].present?
37
+ id_tag_info["expiryDate"] = result[:expiry_date].iso8601
38
+ end
39
+
40
+ {
41
+ "idTagInfo" => id_tag_info
42
+ }
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end