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,62 @@
1
+ module Ocpp
2
+ module Rails
3
+ module Actions
4
+ class BootNotificationHandler
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
+ # Update charge point with boot information
13
+ old_connected = @charge_point.connected
14
+
15
+ @charge_point.update(
16
+ vendor: @payload["chargePointVendor"],
17
+ model: @payload["chargePointModel"],
18
+ serial_number: @payload["chargePointSerialNumber"],
19
+ firmware_version: @payload["firmwareVersion"],
20
+ iccid: @payload["iccid"],
21
+ imsi: @payload["imsi"],
22
+ meter_type: @payload["meterType"],
23
+ meter_serial_number: @payload["meterSerialNumber"],
24
+ connected: true,
25
+ last_heartbeat_at: Time.current
26
+ )
27
+
28
+ ::Rails.logger.info("[OCPP] BootNotification from #{@charge_point.identifier}: #{@payload['chargePointVendor']} #{@payload['chargePointModel']}")
29
+
30
+ # Log connection state change if it actually changed
31
+ log_connection_change(old_connected, true)
32
+
33
+ # Return acceptance with heartbeat interval
34
+ {
35
+ "status" => "Accepted",
36
+ "currentTime" => Time.current.iso8601,
37
+ "interval" => Ocpp::Rails.configuration.heartbeat_interval
38
+ }
39
+ end
40
+
41
+ private
42
+
43
+ def log_connection_change(old_connected, new_connected)
44
+ return if old_connected == new_connected
45
+
46
+ begin
47
+ Ocpp::Rails::StateChange.create!(
48
+ charge_point: @charge_point,
49
+ change_type: "connection",
50
+ connector_id: nil,
51
+ old_value: old_connected.to_s,
52
+ new_value: new_connected.to_s,
53
+ metadata: { source: "boot_notification" }
54
+ )
55
+ rescue => error
56
+ ::Rails.logger.error("Failed to log state change: #{error.message}")
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,25 @@
1
+ module Ocpp
2
+ module Rails
3
+ module Actions
4
+ class HeartbeatHandler
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
+ # Update last heartbeat timestamp and ensure connected status
13
+ @charge_point.heartbeat!
14
+
15
+ ::Rails.logger.debug("[OCPP] Heartbeat from #{@charge_point.identifier}")
16
+
17
+ # Return current server time
18
+ {
19
+ "currentTime" => Time.current.iso8601
20
+ }
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,97 @@
1
+ module Ocpp
2
+ module Rails
3
+ module Actions
4
+ class MeterValuesHandler
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
+ connector_id = @payload["connectorId"]
13
+ transaction_id = @payload["transactionId"]
14
+
15
+ # Find active session if transaction ID provided
16
+ session = if transaction_id
17
+ @charge_point.charging_sessions.find_by(transaction_id: transaction_id)
18
+ else
19
+ @charge_point.charging_sessions.active.find_by(connector_id: connector_id)
20
+ end
21
+
22
+ # Process each meter value set
23
+ meter_value_sets = @payload["meterValue"] || []
24
+ meter_value_sets.each do |meter_value_set|
25
+ timestamp = TimestampParser.parse(meter_value_set["timestamp"])
26
+
27
+ sampled_values = meter_value_set["sampledValue"] || []
28
+ sampled_values.each do |sampled_value|
29
+ meter_value = create_meter_value(session, connector_id, timestamp, sampled_value)
30
+ broadcast_meter_value(meter_value) if meter_value
31
+ end
32
+ end
33
+
34
+ ::Rails.logger.debug("[OCPP] MeterValues from #{@charge_point.identifier}: Connector #{connector_id}, #{meter_value_sets.size} value sets")
35
+
36
+ # Return empty response (MeterValues doesn't require data in response)
37
+ {}
38
+ end
39
+
40
+ private
41
+
42
+ def create_meter_value(session, connector_id, timestamp, sampled_value)
43
+ measurand = sampled_value["measurand"] || "Energy.Active.Import.Register"
44
+ unit = sampled_value["unit"] || "Wh"
45
+
46
+ flag_reason = MeterAnomalyDetector.check(
47
+ session: session,
48
+ measurand: measurand,
49
+ value: sampled_value["value"],
50
+ unit: unit
51
+ )
52
+ if flag_reason
53
+ ::Rails.logger.warn("[OCPP] Anomalous meter value from #{@charge_point.identifier} (#{flag_reason}): #{sampled_value['value']} #{unit}")
54
+ end
55
+
56
+ @charge_point.meter_values.create!(
57
+ charging_session: session,
58
+ connector_id: connector_id,
59
+ measurand: measurand,
60
+ phase: sampled_value["phase"],
61
+ unit: unit,
62
+ context: sampled_value["context"] || "Sample.Periodic",
63
+ format: sampled_value["format"] || "Raw",
64
+ location: sampled_value["location"] || "Outlet",
65
+ value: sampled_value["value"],
66
+ timestamp: timestamp.time,
67
+ raw_timestamp: timestamp.raw,
68
+ timestamp_source: timestamp.source,
69
+ flagged: flag_reason.present?,
70
+ flag_reason: flag_reason
71
+ )
72
+ rescue => e
73
+ ::Rails.logger.error("[OCPP] Failed to create meter value: #{e.message}")
74
+ nil
75
+ end
76
+
77
+ def broadcast_meter_value(meter_value)
78
+ ActionCable.server.broadcast(
79
+ "charge_point_#{@charge_point.id}_meter_values",
80
+ {
81
+ connector_id: meter_value.connector_id,
82
+ measurand: meter_value.measurand,
83
+ value: meter_value.value.to_f,
84
+ unit: meter_value.unit,
85
+ phase: meter_value.phase,
86
+ context: meter_value.context,
87
+ timestamp: meter_value.timestamp.iso8601,
88
+ session_id: meter_value.charging_session_id
89
+ }
90
+ )
91
+ rescue => e
92
+ ::Rails.logger.error("[OCPP] Failed to broadcast meter value: #{e.message}")
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,126 @@
1
+ module Ocpp
2
+ module Rails
3
+ module Actions
4
+ class StartTransactionHandler
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
+ authorization = authorize(id_tag)
14
+
15
+ unless authorization[:status] == "Accepted"
16
+ ::Rails.logger.warn("[OCPP] StartTransaction from #{@charge_point.identifier} rejected for idTag #{id_tag}: #{authorization[:status]}")
17
+ # OCPP 1.6 requires transactionId in the response; the station
18
+ # must ignore it when the idTag was not accepted.
19
+ return {
20
+ "idTagInfo" => { "status" => authorization[:status] },
21
+ "transactionId" => 0
22
+ }
23
+ end
24
+
25
+ # A duplicate/replayed StartTransaction must not open a second
26
+ # concurrent session on the connector; resume the open one instead.
27
+ existing = active_session
28
+ if existing
29
+ ::Rails.logger.warn("[OCPP] StartTransaction from #{@charge_point.identifier}: Connector #{@payload['connectorId']} already has active transaction #{existing.transaction_id}, resuming it")
30
+ return accepted_response(existing, authorization)
31
+ end
32
+
33
+ started_at = TimestampParser.parse(@payload["timestamp"])
34
+
35
+ begin
36
+ session = @charge_point.charging_sessions.create!(
37
+ connector_id: @payload["connectorId"],
38
+ id_tag: id_tag,
39
+ start_meter_value: @payload["meterStart"],
40
+ started_at: started_at.time,
41
+ status: "Charging",
42
+ metadata: session_metadata(started_at)
43
+ )
44
+ rescue ActiveRecord::RecordNotUnique
45
+ # Lost a race against a concurrent StartTransaction; the winner's
46
+ # session is the one to resume.
47
+ session = active_session
48
+ raise unless session
49
+ return accepted_response(session, authorization)
50
+ end
51
+
52
+ ::Rails.logger.info("[OCPP] StartTransaction from #{@charge_point.identifier}: Connector #{@payload['connectorId']}, Transaction ID: #{session.transaction_id}")
53
+
54
+ # Update charge point status
55
+ @charge_point.update(status: "Charging")
56
+
57
+ # Broadcast session started event for real-time UI updates
58
+ broadcast_session_started(session)
59
+
60
+ accepted_response(session, authorization)
61
+ end
62
+
63
+ private
64
+
65
+ def active_session
66
+ @charge_point.charging_sessions.active.find_by(connector_id: @payload["connectorId"])
67
+ end
68
+
69
+ def accepted_response(session, authorization)
70
+ id_tag_info = { "status" => "Accepted" }
71
+ id_tag_info["expiryDate"] = authorization[:expiry_date].iso8601 if authorization[:expiry_date].present?
72
+
73
+ {
74
+ "idTagInfo" => id_tag_info,
75
+ "transactionId" => session.transaction_id
76
+ }
77
+ end
78
+
79
+ # Same decision the AuthorizeHandler makes, persisted for audit.
80
+ def authorize(id_tag)
81
+ result = Ocpp::Rails::AuthorizationHookManager.execute_hooks(@charge_point.id, id_tag)
82
+
83
+ begin
84
+ Ocpp::Rails::Authorization.create!(
85
+ charge_point_id: @charge_point.id,
86
+ id_tag: id_tag,
87
+ status: result[:status],
88
+ expiry_date: result[:expiry_date]
89
+ )
90
+ rescue => error
91
+ ::Rails.logger.error("[OCPP] Failed to persist Authorization record: #{error.message}")
92
+ end
93
+
94
+ result
95
+ end
96
+
97
+ def session_metadata(started_at)
98
+ return {} unless started_at.server_fallback?
99
+
100
+ {
101
+ "started_at_source" => started_at.source,
102
+ "raw_start_timestamp" => started_at.raw
103
+ }
104
+ end
105
+
106
+ def broadcast_session_started(session)
107
+ ActionCable.server.broadcast(
108
+ "charge_point_#{@charge_point.id}_sessions",
109
+ {
110
+ event: "started",
111
+ session: {
112
+ id: session.id,
113
+ connector_id: session.connector_id,
114
+ id_tag: session.id_tag,
115
+ started_at: session.started_at.iso8601,
116
+ start_meter_value: session.start_meter_value
117
+ }
118
+ }
119
+ )
120
+ rescue => e
121
+ ::Rails.logger.error("[OCPP] Failed to broadcast session started: #{e.message}")
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,99 @@
1
+ module Ocpp
2
+ module Rails
3
+ module Actions
4
+ class StatusNotificationHandler
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
+ connector_id = @payload["connectorId"]
13
+ status = @payload["status"]
14
+ error_code = @payload["errorCode"]
15
+
16
+ ::Rails.logger.info("[OCPP] StatusNotification from #{@charge_point.identifier}: Connector #{connector_id} is #{status}")
17
+
18
+ # Update charge point or connector status
19
+ if connector_id == 0
20
+ # Status for entire charge point
21
+ old_status = @charge_point.status
22
+ @charge_point.update(status: status)
23
+
24
+ # Log state change if status actually changed
25
+ log_status_change(nil, old_status, status, {
26
+ error_code: error_code,
27
+ info: @payload["info"],
28
+ vendor_id: @payload["vendorId"],
29
+ vendor_error_code: @payload["vendorErrorCode"]
30
+ })
31
+ else
32
+ # Status for specific connector - store in metadata
33
+ update_connector_status(connector_id, status, error_code)
34
+ end
35
+
36
+ # Broadcast status change for real-time UI updates
37
+ broadcast_status_change
38
+
39
+ # Return empty response (StatusNotification doesn't require data in response)
40
+ {}
41
+ end
42
+
43
+ private
44
+
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)
53
+
54
+ # Log state change if status actually changed
55
+ log_status_change(connector_id, old_status, status, {
56
+ error_code: error_code,
57
+ info: @payload["info"],
58
+ vendor_id: @payload["vendorId"],
59
+ vendor_error_code: @payload["vendorErrorCode"]
60
+ })
61
+ end
62
+
63
+ def log_status_change(connector_id, old_status, new_status, additional_metadata = {})
64
+ return if old_status == new_status
65
+
66
+ begin
67
+ Ocpp::Rails::StateChange.create!(
68
+ charge_point: @charge_point,
69
+ change_type: "status",
70
+ connector_id: connector_id,
71
+ old_value: old_status,
72
+ new_value: new_status,
73
+ metadata: additional_metadata.compact
74
+ )
75
+ rescue => error
76
+ ::Rails.logger.error("Failed to log state change: #{error.message}")
77
+ end
78
+ end
79
+
80
+ def broadcast_status_change
81
+ ActionCable.server.broadcast(
82
+ "charge_point_#{@charge_point.id}_status",
83
+ {
84
+ connector_id: @payload["connectorId"],
85
+ status: @payload["status"],
86
+ error_code: @payload["errorCode"],
87
+ info: @payload["info"],
88
+ vendor_id: @payload["vendorId"],
89
+ vendor_error_code: @payload["vendorErrorCode"],
90
+ timestamp: @payload["timestamp"] || Time.current.iso8601
91
+ }
92
+ )
93
+ rescue => e
94
+ ::Rails.logger.error("[OCPP] Failed to broadcast status change: #{e.message}")
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,121 @@
1
+ module Ocpp
2
+ module Rails
3
+ module Actions
4
+ class StopTransactionHandler
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
+ # Find the session by the wire transaction ID
13
+ session = @charge_point.charging_sessions.find_by(transaction_id: @payload["transactionId"])
14
+
15
+ unless session
16
+ ::Rails.logger.error("[OCPP] StopTransaction: Session not found for transaction ID #{@payload['transactionId']}")
17
+ return {
18
+ "idTagInfo" => {
19
+ "status" => "Invalid"
20
+ }
21
+ }
22
+ end
23
+
24
+ # Stop the session
25
+ session.stop!(
26
+ reason: @payload["reason"] || "Local",
27
+ meter_value: @payload["meterStop"]
28
+ )
29
+
30
+ ::Rails.logger.info("[OCPP] StopTransaction from #{@charge_point.identifier}: Transaction ID #{session.transaction_id}, Energy: #{session.energy_consumed} Wh")
31
+
32
+ # Process transaction data (meter values during charging) if provided
33
+ if @payload["transactionData"]
34
+ process_transaction_data(session, @payload["transactionData"])
35
+ end
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
41
+
42
+ # Broadcast session stopped event for real-time UI updates
43
+ broadcast_session_stopped(session)
44
+
45
+ # Return authorization status
46
+ {
47
+ "idTagInfo" => {
48
+ "status" => "Accepted"
49
+ }
50
+ }
51
+ end
52
+
53
+ private
54
+
55
+ def process_transaction_data(session, transaction_data)
56
+ transaction_data.each do |meter_values_set|
57
+ timestamp = TimestampParser.parse(meter_values_set["timestamp"])
58
+
59
+ sampled_values = meter_values_set["sampledValue"] || []
60
+ sampled_values.each do |sampled_value|
61
+ create_meter_value(session, timestamp, sampled_value)
62
+ end
63
+ end
64
+ rescue => e
65
+ ::Rails.logger.error("[OCPP] Error processing transaction data: #{e.message}")
66
+ end
67
+
68
+ def create_meter_value(session, timestamp, sampled_value)
69
+ measurand = sampled_value["measurand"] || "Energy.Active.Import.Register"
70
+ unit = sampled_value["unit"] || "Wh"
71
+
72
+ flag_reason = MeterAnomalyDetector.check(
73
+ session: session,
74
+ measurand: measurand,
75
+ value: sampled_value["value"],
76
+ unit: unit
77
+ )
78
+ if flag_reason
79
+ ::Rails.logger.warn("[OCPP] Anomalous meter value from #{@charge_point.identifier} (#{flag_reason}): #{sampled_value['value']} #{unit}")
80
+ end
81
+
82
+ session.meter_values.create!(
83
+ charge_point: @charge_point,
84
+ connector_id: session.connector_id,
85
+ measurand: measurand,
86
+ phase: sampled_value["phase"],
87
+ unit: unit,
88
+ context: sampled_value["context"] || "Transaction.End",
89
+ format: sampled_value["format"] || "Raw",
90
+ location: sampled_value["location"] || "Outlet",
91
+ value: sampled_value["value"],
92
+ timestamp: timestamp.time,
93
+ raw_timestamp: timestamp.raw,
94
+ timestamp_source: timestamp.source,
95
+ flagged: flag_reason.present?,
96
+ flag_reason: flag_reason
97
+ )
98
+ end
99
+
100
+ def broadcast_session_stopped(session)
101
+ ActionCable.server.broadcast(
102
+ "charge_point_#{@charge_point.id}_sessions",
103
+ {
104
+ event: "stopped",
105
+ session: {
106
+ id: session.id,
107
+ connector_id: session.connector_id,
108
+ stopped_at: session.stopped_at.iso8601,
109
+ energy_consumed: session.energy_consumed,
110
+ duration_seconds: session.duration_seconds,
111
+ stop_reason: session.stop_reason
112
+ }
113
+ }
114
+ )
115
+ rescue => e
116
+ ::Rails.logger.error("[OCPP] Failed to broadcast session stopped: #{e.message}")
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,102 @@
1
+ module Ocpp
2
+ module Rails
3
+ module AuthorizationHookManager
4
+ VALID_STATUSES = [ "Accepted", "Blocked", "Expired", "Invalid", "ConcurrentTx" ].freeze
5
+ DEFAULT_EXPIRY = 1.year
6
+
7
+ def self.execute_hooks(charge_point_id, id_tag)
8
+ hooks = Ocpp::Rails.configuration.authorization_hooks
9
+ sync_hooks = hooks.reject { |hook| hook.respond_to?(:async?) && hook.async? }
10
+
11
+ # If no sync hooks, default to accepted
12
+ if sync_hooks.empty?
13
+ return {
14
+ status: "Accepted",
15
+ expiry_date: Time.current + DEFAULT_EXPIRY
16
+ }
17
+ end
18
+
19
+ # Execute sync hooks
20
+ expiry_dates = []
21
+
22
+ sync_hooks.each do |hook|
23
+ begin
24
+ result = hook.call(charge_point_id, id_tag)
25
+
26
+ # Validate return value
27
+ unless result.is_a?(Hash)
28
+ ::Rails.logger.error("AuthorizationHook #{hook.class.name} returned non-hash: #{result.inspect}")
29
+ return { status: "Invalid", expiry_date: nil }
30
+ end
31
+
32
+ # Normalize keys to strings
33
+ result = result.stringify_keys
34
+
35
+ # Validate status key exists
36
+ unless result.key?("status")
37
+ ::Rails.logger.error("AuthorizationHook #{hook.class.name} returned hash without status key: #{result.inspect}")
38
+ return { status: "Invalid", expiry_date: nil }
39
+ end
40
+
41
+ status = result["status"]
42
+
43
+ # Validate status value
44
+ unless VALID_STATUSES.include?(status)
45
+ ::Rails.logger.error("AuthorizationHook #{hook.class.name} returned invalid status: #{status}")
46
+ return { status: "Invalid", expiry_date: nil }
47
+ end
48
+
49
+ # If rejected, return immediately
50
+ if status != "Accepted"
51
+ ::Rails.logger.info("AuthorizationHook #{hook.class.name} rejected with status: #{status}")
52
+ return { status: status, expiry_date: nil }
53
+ end
54
+
55
+ # If accepted, collect expiry date
56
+ if result["expiry_date"].present?
57
+ expiry_dates << parse_expiry_date(result["expiry_date"])
58
+ end
59
+ rescue => error
60
+ ::Rails.logger.error("AuthorizationHook #{hook.class.name} raised exception: #{error.message}")
61
+ ::Rails.logger.error(error.backtrace.join("\n"))
62
+ return { status: "Invalid", expiry_date: nil }
63
+ end
64
+ end
65
+
66
+ # All hooks accepted, return with latest expiry
67
+ {
68
+ status: "Accepted",
69
+ expiry_date: expiry_dates.max || (Time.current + DEFAULT_EXPIRY)
70
+ }
71
+ end
72
+
73
+ def self.execute_async_hooks(authorization)
74
+ hooks = Ocpp::Rails.configuration.authorization_hooks
75
+ async_hooks = hooks.select { |hook| hook.respond_to?(:async?) && hook.async? }
76
+
77
+ async_hooks.each do |hook|
78
+ begin
79
+ Ocpp::Rails::AuthorizationAsyncHookJob.perform_later(authorization.id, hook.class.name)
80
+ rescue => error
81
+ ::Rails.logger.error("Failed to enqueue AuthorizationAsyncHookJob for #{hook.class.name}: #{error.message}")
82
+ end
83
+ end
84
+
85
+ true
86
+ end
87
+
88
+ private
89
+
90
+ def self.parse_expiry_date(expiry_date)
91
+ return expiry_date if expiry_date.is_a?(Time) || expiry_date.is_a?(DateTime) || expiry_date.is_a?(ActiveSupport::TimeWithZone)
92
+
93
+ begin
94
+ Time.parse(expiry_date.to_s)
95
+ rescue
96
+ ::Rails.logger.error("Failed to parse expiry_date: #{expiry_date.inspect}, using default")
97
+ Time.current + DEFAULT_EXPIRY
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end