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,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,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
|