ocpp-rails 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +461 -0
- data/Rakefile +8 -0
- data/app/assets/stylesheets/ocpp/rails/application.css +15 -0
- data/app/channels/ocpp/rails/charge_point_channel.rb +94 -0
- data/app/controllers/ocpp/rails/application_controller.rb +7 -0
- data/app/helpers/ocpp/rails/application_helper.rb +6 -0
- data/app/jobs/ocpp/rails/application_job.rb +6 -0
- data/app/jobs/ocpp/rails/async_hook_job.rb +25 -0
- data/app/jobs/ocpp/rails/authorization_async_hook_job.rb +25 -0
- data/app/jobs/ocpp/rails/change_availability_job.rb +42 -0
- data/app/jobs/ocpp/rails/change_configuration_job.rb +41 -0
- data/app/jobs/ocpp/rails/cleanup_authorizations_job.rb +28 -0
- data/app/jobs/ocpp/rails/cleanup_state_changes_job.rb +28 -0
- data/app/jobs/ocpp/rails/clear_cache_job.rb +37 -0
- data/app/jobs/ocpp/rails/get_configuration_job.rb +38 -0
- data/app/jobs/ocpp/rails/remote_start_transaction_job.rb +39 -0
- data/app/jobs/ocpp/rails/remote_stop_transaction_job.rb +38 -0
- data/app/jobs/ocpp/rails/reset_job.rb +39 -0
- data/app/jobs/ocpp/rails/unlock_connector_job.rb +38 -0
- data/app/mailers/ocpp/rails/application_mailer.rb +8 -0
- data/app/models/ocpp/rails/application_record.rb +7 -0
- data/app/models/ocpp/rails/authorization.rb +26 -0
- data/app/models/ocpp/rails/charge_point.rb +69 -0
- data/app/models/ocpp/rails/charging_session.rb +72 -0
- data/app/models/ocpp/rails/message.rb +17 -0
- data/app/models/ocpp/rails/meter_value.rb +24 -0
- data/app/models/ocpp/rails/state_change.rb +26 -0
- data/app/services/ocpp/rails/actions/authorize_handler.rb +47 -0
- data/app/services/ocpp/rails/actions/boot_notification_handler.rb +62 -0
- data/app/services/ocpp/rails/actions/heartbeat_handler.rb +25 -0
- data/app/services/ocpp/rails/actions/meter_values_handler.rb +97 -0
- data/app/services/ocpp/rails/actions/start_transaction_handler.rb +126 -0
- data/app/services/ocpp/rails/actions/status_notification_handler.rb +99 -0
- data/app/services/ocpp/rails/actions/stop_transaction_handler.rb +121 -0
- data/app/services/ocpp/rails/authorization_hook_manager.rb +102 -0
- data/app/services/ocpp/rails/message_handler.rb +153 -0
- data/app/services/ocpp/rails/meter_anomaly_detector.rb +50 -0
- data/app/services/ocpp/rails/protocol.rb +65 -0
- data/app/services/ocpp/rails/rate_limiter.rb +53 -0
- data/app/services/ocpp/rails/state_change_hook_manager.rb +26 -0
- data/app/services/ocpp/rails/station_authenticator.rb +46 -0
- data/app/services/ocpp/rails/timestamp_parser.rb +25 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20251008162902_create_ocpp_models.rb +77 -0
- data/db/migrate/20251015190754_make_charging_session_id_nullable_in_meter_values.rb +5 -0
- data/db/migrate/20251016000000_create_ocpp_state_changes.rb +17 -0
- data/db/migrate/20251017000000_create_ocpp_authorizations.rb +15 -0
- data/db/migrate/20260707000000_add_timestamp_provenance_to_ocpp_meter_values.rb +6 -0
- data/db/migrate/20260707000001_convert_ocpp_transaction_id_to_integer.rb +19 -0
- data/db/migrate/20260707000002_add_active_session_guard_to_ocpp_charging_sessions.rb +11 -0
- data/db/migrate/20260707000003_add_anomaly_flags_to_ocpp_meter_values.rb +6 -0
- data/db/migrate/20260707000004_add_auth_password_digest_to_ocpp_charge_points.rb +5 -0
- data/lib/generators/ocpp/rails/install_generator.rb +42 -0
- data/lib/generators/ocpp/rails/templates/README +45 -0
- data/lib/generators/ocpp/rails/templates/create_ocpp_charge_points.rb +21 -0
- data/lib/generators/ocpp/rails/templates/create_ocpp_charging_sessions.rb +22 -0
- data/lib/generators/ocpp/rails/templates/create_ocpp_messages.rb +18 -0
- data/lib/generators/ocpp/rails/templates/create_ocpp_meter_values.rb +21 -0
- data/lib/generators/ocpp/rails/templates/ocpp_rails.rb +13 -0
- data/lib/ocpp/rails/engine.rb +19 -0
- data/lib/ocpp/rails/version.rb +5 -0
- data/lib/ocpp/rails.rb +82 -0
- data/lib/tasks/ocpp/authorizations.rake +18 -0
- data/lib/tasks/ocpp/rails_tasks.rake +4 -0
- data/lib/tasks/ocpp/state_changes.rake +18 -0
- metadata +196 -0
|
@@ -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
|