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,153 @@
|
|
|
1
|
+
module Ocpp
|
|
2
|
+
module Rails
|
|
3
|
+
class MessageHandler
|
|
4
|
+
attr_reader :charge_point, :raw_message
|
|
5
|
+
|
|
6
|
+
def initialize(charge_point, raw_message)
|
|
7
|
+
@charge_point = charge_point
|
|
8
|
+
@raw_message = raw_message
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def process
|
|
12
|
+
parsed = Protocol.parse(raw_message)
|
|
13
|
+
|
|
14
|
+
case parsed[:type]
|
|
15
|
+
when "CALL"
|
|
16
|
+
handle_call(parsed)
|
|
17
|
+
when "CALLRESULT"
|
|
18
|
+
handle_callresult(parsed)
|
|
19
|
+
when "CALLERROR"
|
|
20
|
+
handle_callerror(parsed)
|
|
21
|
+
when "PARSE_ERROR"
|
|
22
|
+
log_error("Failed to parse message: #{parsed[:error]}")
|
|
23
|
+
send_callerror(SecureRandom.uuid, "FormationViolation", "Invalid JSON format")
|
|
24
|
+
when "UNKNOWN"
|
|
25
|
+
log_error("Unknown message type: #{parsed[:raw]}")
|
|
26
|
+
send_callerror(SecureRandom.uuid, "ProtocolError", "Unknown message type")
|
|
27
|
+
end
|
|
28
|
+
rescue => e
|
|
29
|
+
send_internal_error(parsed && parsed[:message_id], e, "Error processing message")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Never place exception details in the CALLERROR sent to the station;
|
|
35
|
+
# log them server-side under a reference the station response carries.
|
|
36
|
+
def send_internal_error(message_id, exception, context)
|
|
37
|
+
error_ref = SecureRandom.hex(8)
|
|
38
|
+
log_error("#{context} (ref #{error_ref}): #{exception.class}: #{exception.message}")
|
|
39
|
+
log_error(exception.backtrace.join("\n")) if exception.backtrace
|
|
40
|
+
send_callerror(
|
|
41
|
+
message_id || SecureRandom.uuid,
|
|
42
|
+
"InternalError",
|
|
43
|
+
"An internal error occurred",
|
|
44
|
+
{ "errorRef" => error_ref }
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def handle_call(parsed)
|
|
49
|
+
# Log incoming message
|
|
50
|
+
log_message(parsed, "inbound", "CALL")
|
|
51
|
+
|
|
52
|
+
# Route to appropriate handler
|
|
53
|
+
handler_class_name = "Ocpp::Rails::Actions::#{parsed[:action]}Handler"
|
|
54
|
+
|
|
55
|
+
begin
|
|
56
|
+
handler_class = handler_class_name.constantize
|
|
57
|
+
rescue NameError
|
|
58
|
+
send_callerror(parsed[:message_id], "NotSupported", "Action #{parsed[:action]} not supported")
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
handler = handler_class.new(charge_point, parsed[:message_id], parsed[:payload])
|
|
63
|
+
response = handler.call
|
|
64
|
+
send_callresult(parsed[:message_id], response)
|
|
65
|
+
rescue => e
|
|
66
|
+
send_internal_error(parsed[:message_id], e, "Error in #{parsed[:action]} handler")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def handle_callresult(parsed)
|
|
70
|
+
# Update pending outbound message with successful response
|
|
71
|
+
message = Message.find_by(
|
|
72
|
+
charge_point: charge_point,
|
|
73
|
+
message_id: parsed[:message_id],
|
|
74
|
+
direction: "outbound",
|
|
75
|
+
status: "pending"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if message
|
|
79
|
+
message.update(
|
|
80
|
+
status: "received",
|
|
81
|
+
payload: message.payload.merge(response: parsed[:payload])
|
|
82
|
+
)
|
|
83
|
+
log_info("Received CALLRESULT for #{message.action}")
|
|
84
|
+
else
|
|
85
|
+
log_warn("Received CALLRESULT for unknown message_id: #{parsed[:message_id]}")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def handle_callerror(parsed)
|
|
90
|
+
# Update pending outbound message with error response
|
|
91
|
+
message = Message.find_by(
|
|
92
|
+
charge_point: charge_point,
|
|
93
|
+
message_id: parsed[:message_id],
|
|
94
|
+
direction: "outbound",
|
|
95
|
+
status: "pending"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if message
|
|
99
|
+
message.update(
|
|
100
|
+
status: "error",
|
|
101
|
+
error_message: "#{parsed[:error_code]}: #{parsed[:error_description]}",
|
|
102
|
+
payload: message.payload.merge(error_details: parsed[:details])
|
|
103
|
+
)
|
|
104
|
+
log_error("Received CALLERROR for #{message.action}: #{parsed[:error_code]}")
|
|
105
|
+
else
|
|
106
|
+
log_warn("Received CALLERROR for unknown message_id: #{parsed[:message_id]}")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def send_callresult(message_id, payload)
|
|
111
|
+
response = Protocol.build_callresult(message_id, payload)
|
|
112
|
+
ChargePointChannel.broadcast_to(charge_point, { message: response })
|
|
113
|
+
log_message({ message_id: message_id, payload: payload }, "outbound", "CALLRESULT")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def send_callerror(message_id, error_code, error_description, details = {})
|
|
117
|
+
response = Protocol.build_callerror(message_id, error_code, error_description, details)
|
|
118
|
+
ChargePointChannel.broadcast_to(charge_point, { message: response })
|
|
119
|
+
log_message(
|
|
120
|
+
{ message_id: message_id, error_code: error_code, error_description: error_description },
|
|
121
|
+
"outbound",
|
|
122
|
+
"CALLERROR"
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def log_message(parsed, direction, message_type)
|
|
127
|
+
Message.create!(
|
|
128
|
+
charge_point: charge_point,
|
|
129
|
+
message_id: parsed[:message_id],
|
|
130
|
+
direction: direction,
|
|
131
|
+
action: parsed[:action],
|
|
132
|
+
message_type: message_type,
|
|
133
|
+
payload: parsed[:payload] || parsed[:error_code] || {},
|
|
134
|
+
status: direction == "inbound" ? "received" : "sent"
|
|
135
|
+
)
|
|
136
|
+
rescue => e
|
|
137
|
+
log_error("Failed to log message: #{e.message}")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def log_error(message)
|
|
141
|
+
::Rails.logger.error("[OCPP] ChargePoint #{charge_point.identifier}: #{message}")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def log_warn(message)
|
|
145
|
+
::Rails.logger.warn("[OCPP] ChargePoint #{charge_point.identifier}: #{message}")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def log_info(message)
|
|
149
|
+
::Rails.logger.info("[OCPP] ChargePoint #{charge_point.identifier}: #{message}")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Ocpp
|
|
2
|
+
module Rails
|
|
3
|
+
# Checks Energy.Active.Import.Register readings against the session's
|
|
4
|
+
# series: the register must never decrease (rollover, meter swap) and
|
|
5
|
+
# must not jump implausibly between samples. Anomalous readings are
|
|
6
|
+
# flagged for review instead of silently entering energy accounting.
|
|
7
|
+
module MeterAnomalyDetector
|
|
8
|
+
ENERGY_MEASURAND = "Energy.Active.Import.Register".freeze
|
|
9
|
+
|
|
10
|
+
REGISTER_DECREASE = "register_decrease".freeze
|
|
11
|
+
IMPLAUSIBLE_JUMP = "implausible_jump".freeze
|
|
12
|
+
|
|
13
|
+
# Returns a flag reason string, or nil when the reading is plausible.
|
|
14
|
+
def self.check(session:, measurand:, value:, unit:)
|
|
15
|
+
return nil unless session && measurand == ENERGY_MEASURAND
|
|
16
|
+
|
|
17
|
+
candidate = normalize_to_wh(value, unit)
|
|
18
|
+
return nil unless candidate
|
|
19
|
+
|
|
20
|
+
baseline = baseline_wh(session)
|
|
21
|
+
return nil unless baseline
|
|
22
|
+
|
|
23
|
+
max_jump = Ocpp::Rails.configuration.implausible_energy_jump_wh
|
|
24
|
+
if candidate < baseline
|
|
25
|
+
REGISTER_DECREASE
|
|
26
|
+
elsif max_jump && candidate - baseline > max_jump
|
|
27
|
+
IMPLAUSIBLE_JUMP
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.normalize_to_wh(value, unit)
|
|
32
|
+
return nil if value.nil?
|
|
33
|
+
|
|
34
|
+
wh = BigDecimal(value.to_s)
|
|
35
|
+
unit == "kWh" ? wh * 1000 : wh
|
|
36
|
+
rescue ArgumentError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Highest trustworthy register reading seen so far: meterStart plus all
|
|
41
|
+
# previously accepted (unflagged) energy samples, normalised to Wh.
|
|
42
|
+
def self.baseline_wh(session)
|
|
43
|
+
previous = session.meter_values.energy.where(flagged: false).maximum(
|
|
44
|
+
Arel.sql("CASE WHEN unit = 'kWh' THEN value * 1000 ELSE value END")
|
|
45
|
+
)
|
|
46
|
+
[ previous, session.start_meter_value ].compact.max
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Ocpp
|
|
2
|
+
module Rails
|
|
3
|
+
module Protocol
|
|
4
|
+
class << self
|
|
5
|
+
# Build a CALL message (request from server to charge point or charge point to server)
|
|
6
|
+
# Format: [2, "message_id", "Action", {payload}]
|
|
7
|
+
def build_call(message_id, action, payload)
|
|
8
|
+
[ 2, message_id, action, payload ].to_json
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Build a CALLRESULT message (successful response)
|
|
12
|
+
# Format: [3, "message_id", {payload}]
|
|
13
|
+
def build_callresult(message_id, payload)
|
|
14
|
+
[ 3, message_id, payload ].to_json
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Build a CALLERROR message (error response)
|
|
18
|
+
# Format: [4, "message_id", "error_code", "error_description", {details}]
|
|
19
|
+
def build_callerror(message_id, error_code, error_description, details = {})
|
|
20
|
+
[ 4, message_id, error_code, error_description, details ].to_json
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Parse incoming JSON-RPC 2.0 message
|
|
24
|
+
def parse(raw_message)
|
|
25
|
+
data = JSON.parse(raw_message)
|
|
26
|
+
|
|
27
|
+
case data[0]
|
|
28
|
+
when 2
|
|
29
|
+
{
|
|
30
|
+
type: "CALL",
|
|
31
|
+
message_id: data[1],
|
|
32
|
+
action: data[2],
|
|
33
|
+
payload: data[3] || {}
|
|
34
|
+
}
|
|
35
|
+
when 3
|
|
36
|
+
{
|
|
37
|
+
type: "CALLRESULT",
|
|
38
|
+
message_id: data[1],
|
|
39
|
+
payload: data[2] || {}
|
|
40
|
+
}
|
|
41
|
+
when 4
|
|
42
|
+
{
|
|
43
|
+
type: "CALLERROR",
|
|
44
|
+
message_id: data[1],
|
|
45
|
+
error_code: data[2],
|
|
46
|
+
error_description: data[3],
|
|
47
|
+
details: data[4] || {}
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
{
|
|
51
|
+
type: "UNKNOWN",
|
|
52
|
+
raw: data
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
rescue JSON::ParserError => e
|
|
56
|
+
{
|
|
57
|
+
type: "PARSE_ERROR",
|
|
58
|
+
error: e.message,
|
|
59
|
+
raw: raw_message
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Ocpp
|
|
2
|
+
module Rails
|
|
3
|
+
# Fixed-window rate limiter keyed by station identifier. The limit is
|
|
4
|
+
# re-read from the block on every call so configuration changes apply
|
|
5
|
+
# immediately; a nil limit disables throttling.
|
|
6
|
+
#
|
|
7
|
+
# State is in-process: in multi-server deployments each node applies
|
|
8
|
+
# the limit independently, so treat the configured value as per node.
|
|
9
|
+
class RateLimiter
|
|
10
|
+
PRUNE_THRESHOLD = 1_000
|
|
11
|
+
|
|
12
|
+
def initialize(window: 60, &limit)
|
|
13
|
+
@window = window
|
|
14
|
+
@limit = limit
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
@windows = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns true when the event for this key is within the limit.
|
|
20
|
+
def allow?(key, now: Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
21
|
+
limit = @limit.call
|
|
22
|
+
return true if limit.nil?
|
|
23
|
+
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
prune(now) if @windows.size > PRUNE_THRESHOLD
|
|
26
|
+
|
|
27
|
+
started_at, count = @windows[key]
|
|
28
|
+
if started_at.nil? || now - started_at >= @window
|
|
29
|
+
@windows[key] = [ now, 1 ]
|
|
30
|
+
true
|
|
31
|
+
elsif count < limit
|
|
32
|
+
@windows[key][1] += 1
|
|
33
|
+
true
|
|
34
|
+
else
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def reset!
|
|
41
|
+
@mutex.synchronize { @windows.clear }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Drop expired windows so one-off keys (e.g. unknown identifiers
|
|
47
|
+
# hammering the endpoint) cannot grow the map unboundedly.
|
|
48
|
+
def prune(now)
|
|
49
|
+
@windows.delete_if { |_key, (started_at, _count)| now - started_at >= @window }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Ocpp
|
|
2
|
+
module Rails
|
|
3
|
+
module StateChangeHookManager
|
|
4
|
+
def self.execute_hooks(state_change)
|
|
5
|
+
hooks = Ocpp::Rails.configuration.state_change_hooks
|
|
6
|
+
|
|
7
|
+
hooks.each do |hook|
|
|
8
|
+
begin
|
|
9
|
+
if hook.respond_to?(:async?) && hook.async?
|
|
10
|
+
# Enqueue async hook job
|
|
11
|
+
Ocpp::Rails::AsyncHookJob.perform_later(state_change.id, hook.class.name)
|
|
12
|
+
else
|
|
13
|
+
# Execute synchronously
|
|
14
|
+
hook.call(state_change)
|
|
15
|
+
end
|
|
16
|
+
rescue => error
|
|
17
|
+
::Rails.logger.error("StateChangeHook #{hook.class.name} failed: #{error.message}")
|
|
18
|
+
::Rails.logger.error(error.backtrace.join("\n"))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Ocpp
|
|
2
|
+
module Rails
|
|
3
|
+
# OCPP-J Security Profile 1: HTTP Basic Auth on the WebSocket upgrade.
|
|
4
|
+
# The username must equal the charge point identity and the password must
|
|
5
|
+
# match the per-station credential stored (hashed) on the ChargePoint.
|
|
6
|
+
# Profile 2 additionally requires TLS, terminated in front of the app.
|
|
7
|
+
module StationAuthenticator
|
|
8
|
+
Result = Struct.new(:charge_point, :failure) do
|
|
9
|
+
def success?
|
|
10
|
+
failure.nil?
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.authenticate(identifier:, authorization_header:)
|
|
15
|
+
charge_point = ChargePoint.find_by(identifier: identifier)
|
|
16
|
+
return failure(:unknown_charge_point) unless charge_point
|
|
17
|
+
|
|
18
|
+
return Result.new(charge_point, nil) if Ocpp::Rails.configuration.authentication_mode == :none
|
|
19
|
+
|
|
20
|
+
username, password = decode_basic(authorization_header)
|
|
21
|
+
return failure(:missing_credentials) if username.nil?
|
|
22
|
+
|
|
23
|
+
# OCPP-J requires the Basic Auth username to equal the station identity
|
|
24
|
+
return failure(:identity_mismatch) unless username == identifier
|
|
25
|
+
return failure(:no_credential_configured) if charge_point.auth_password_digest.blank?
|
|
26
|
+
return failure(:invalid_credentials) unless charge_point.authenticate_password?(password)
|
|
27
|
+
|
|
28
|
+
Result.new(charge_point, nil)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.decode_basic(header)
|
|
32
|
+
return nil unless header.is_a?(String) && header.start_with?("Basic ")
|
|
33
|
+
|
|
34
|
+
decoded = Base64.strict_decode64(header.delete_prefix("Basic "))
|
|
35
|
+
decoded.split(":", 2)
|
|
36
|
+
rescue ArgumentError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.failure(reason)
|
|
41
|
+
Result.new(nil, reason)
|
|
42
|
+
end
|
|
43
|
+
private_class_method :failure
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Ocpp
|
|
2
|
+
module Rails
|
|
3
|
+
# Parses station-provided OCPP timestamps without hiding failures:
|
|
4
|
+
# a value that cannot be parsed falls back to server receive time,
|
|
5
|
+
# but the result is marked so the record can be flagged instead of
|
|
6
|
+
# silently entering the time series as fabricated data.
|
|
7
|
+
module TimestampParser
|
|
8
|
+
SOURCE_STATION = "station".freeze
|
|
9
|
+
SOURCE_SERVER_FALLBACK = "server_fallback".freeze
|
|
10
|
+
|
|
11
|
+
Result = Struct.new(:time, :source, :raw, keyword_init: true) do
|
|
12
|
+
def server_fallback?
|
|
13
|
+
source == SOURCE_SERVER_FALLBACK
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.parse(raw)
|
|
18
|
+
Result.new(time: Time.parse(raw), source: SOURCE_STATION, raw: raw)
|
|
19
|
+
rescue ArgumentError, TypeError
|
|
20
|
+
::Rails.logger.warn("[OCPP] Unparseable timestamp #{raw.inspect}; falling back to server time")
|
|
21
|
+
Result.new(time: Time.current, source: SOURCE_SERVER_FALLBACK, raw: raw)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
class CreateOcppModels < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# Charge Points
|
|
4
|
+
create_table :ocpp_charge_points do |t|
|
|
5
|
+
t.string :identifier, null: false, index: { unique: true }
|
|
6
|
+
t.string :vendor
|
|
7
|
+
t.string :model
|
|
8
|
+
t.string :serial_number
|
|
9
|
+
t.string :firmware_version
|
|
10
|
+
t.string :iccid
|
|
11
|
+
t.string :imsi
|
|
12
|
+
t.string :meter_type
|
|
13
|
+
t.string :meter_serial_number
|
|
14
|
+
t.string :ocpp_protocol, default: "1.6"
|
|
15
|
+
t.string :status, default: "Available"
|
|
16
|
+
t.datetime :last_heartbeat_at
|
|
17
|
+
t.boolean :connected, default: false
|
|
18
|
+
t.json :metadata, default: {}
|
|
19
|
+
t.timestamps
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Charge Sessions
|
|
23
|
+
create_table :ocpp_charging_sessions do |t|
|
|
24
|
+
t.references :charge_point, null: false, foreign_key: { to_table: :ocpp_charge_points }
|
|
25
|
+
t.integer :connector_id, null: false
|
|
26
|
+
t.string :transaction_id, index: { unique: true }
|
|
27
|
+
t.string :id_tag
|
|
28
|
+
t.string :status, default: "Preparing"
|
|
29
|
+
t.datetime :started_at
|
|
30
|
+
t.datetime :stopped_at
|
|
31
|
+
t.decimal :start_meter_value, precision: 10, scale: 2
|
|
32
|
+
t.decimal :stop_meter_value, precision: 10, scale: 2
|
|
33
|
+
t.decimal :energy_consumed, precision: 10, scale: 2
|
|
34
|
+
t.integer :duration_seconds
|
|
35
|
+
t.string :stop_reason
|
|
36
|
+
t.json :metadata, default: {}
|
|
37
|
+
t.timestamps
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
add_index :ocpp_charging_sessions, [ :charge_point_id, :connector_id ]
|
|
41
|
+
|
|
42
|
+
# Meter Values
|
|
43
|
+
create_table :ocpp_meter_values do |t|
|
|
44
|
+
t.references :charging_session, null: true, foreign_key: { to_table: :ocpp_charging_sessions }
|
|
45
|
+
t.references :charge_point, null: false, foreign_key: { to_table: :ocpp_charge_points }
|
|
46
|
+
t.integer :connector_id
|
|
47
|
+
t.string :measurand
|
|
48
|
+
t.string :phase
|
|
49
|
+
t.string :unit
|
|
50
|
+
t.string :context
|
|
51
|
+
t.string :format
|
|
52
|
+
t.string :location
|
|
53
|
+
t.decimal :value, precision: 15, scale: 4
|
|
54
|
+
t.datetime :timestamp
|
|
55
|
+
t.timestamps
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
add_index :ocpp_meter_values, :measurand
|
|
59
|
+
add_index :ocpp_meter_values, :timestamp
|
|
60
|
+
|
|
61
|
+
# Messages
|
|
62
|
+
create_table :ocpp_messages do |t|
|
|
63
|
+
t.references :charge_point, null: false, foreign_key: { to_table: :ocpp_charge_points }
|
|
64
|
+
t.string :message_id, null: false
|
|
65
|
+
t.string :direction, null: false # inbound, outbound
|
|
66
|
+
t.string :action
|
|
67
|
+
t.string :message_type # CALL, CALLRESULT, CALLERROR
|
|
68
|
+
t.json :payload, default: {}
|
|
69
|
+
t.string :status # pending, sent, received, error
|
|
70
|
+
t.text :error_message
|
|
71
|
+
t.timestamps
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
add_index :ocpp_messages, :message_id
|
|
75
|
+
add_index :ocpp_messages, [ :charge_point_id, :created_at ]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class CreateOcppStateChanges < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :ocpp_state_changes do |t|
|
|
4
|
+
t.references :charge_point, null: false, foreign_key: { to_table: :ocpp_charge_points, on_delete: :cascade }
|
|
5
|
+
t.string :change_type, null: false
|
|
6
|
+
t.integer :connector_id
|
|
7
|
+
t.string :old_value
|
|
8
|
+
t.string :new_value, null: false
|
|
9
|
+
t.json :metadata, default: {}
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :ocpp_state_changes, [ :charge_point_id, :created_at ]
|
|
14
|
+
add_index :ocpp_state_changes, [ :change_type, :created_at ]
|
|
15
|
+
add_index :ocpp_state_changes, :created_at
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class CreateOcppAuthorizations < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :ocpp_authorizations do |t|
|
|
4
|
+
t.references :charge_point, null: false, foreign_key: { to_table: :ocpp_charge_points, on_delete: :cascade }
|
|
5
|
+
t.string :id_tag, null: false
|
|
6
|
+
t.string :status, null: false
|
|
7
|
+
t.datetime :expiry_date
|
|
8
|
+
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :ocpp_authorizations, :id_tag
|
|
13
|
+
add_index :ocpp_authorizations, :created_at
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class ConvertOcppTransactionIdToInteger < ActiveRecord::Migration[8.0]
|
|
2
|
+
# The old string column held internally generated UUIDs that were never
|
|
3
|
+
# sent on the wire (handlers used the AR primary key instead), so the
|
|
4
|
+
# values are safe to drop. OCPP 1.6 requires an integer transactionId,
|
|
5
|
+
# managed by the central system and decoupled from the primary key.
|
|
6
|
+
def up
|
|
7
|
+
remove_index :ocpp_charging_sessions, :transaction_id
|
|
8
|
+
remove_column :ocpp_charging_sessions, :transaction_id
|
|
9
|
+
add_column :ocpp_charging_sessions, :transaction_id, :bigint
|
|
10
|
+
add_index :ocpp_charging_sessions, :transaction_id, unique: true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def down
|
|
14
|
+
remove_index :ocpp_charging_sessions, :transaction_id
|
|
15
|
+
remove_column :ocpp_charging_sessions, :transaction_id
|
|
16
|
+
add_column :ocpp_charging_sessions, :transaction_id, :string
|
|
17
|
+
add_index :ocpp_charging_sessions, :transaction_id, unique: true
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class AddActiveSessionGuardToOcppChargingSessions < ActiveRecord::Migration[8.0]
|
|
2
|
+
# At most one non-stopped session per (charge point, connector), enforced
|
|
3
|
+
# at the database so duplicate/racing StartTransaction messages cannot open
|
|
4
|
+
# concurrent sessions. Partial indexes are supported by SQLite and Postgres.
|
|
5
|
+
def change
|
|
6
|
+
add_index :ocpp_charging_sessions, [ :charge_point_id, :connector_id ],
|
|
7
|
+
unique: true,
|
|
8
|
+
where: "stopped_at IS NULL",
|
|
9
|
+
name: "index_ocpp_one_active_session_per_connector"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/migration"
|
|
3
|
+
|
|
4
|
+
module Ocpp
|
|
5
|
+
module Rails
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
8
|
+
include ::Rails::Generators::Migration
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
desc "Installs Ocpp::Rails into your application"
|
|
13
|
+
|
|
14
|
+
def self.next_migration_number(path)
|
|
15
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def copy_migrations
|
|
19
|
+
migration_template "create_ocpp_charge_points.rb", "db/migrate/create_ocpp_charge_points.rb"
|
|
20
|
+
sleep 1
|
|
21
|
+
migration_template "create_ocpp_charging_sessions.rb", "db/migrate/create_ocpp_charging_sessions.rb"
|
|
22
|
+
sleep 1
|
|
23
|
+
migration_template "create_ocpp_meter_values.rb", "db/migrate/create_ocpp_meter_values.rb"
|
|
24
|
+
sleep 1
|
|
25
|
+
migration_template "create_ocpp_messages.rb", "db/migrate/create_ocpp_messages.rb"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def mount_engine
|
|
29
|
+
route "mount Ocpp::Rails::Engine => '/ocpp'"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def create_initializer
|
|
33
|
+
template "ocpp_rails.rb", "config/initializers/ocpp_rails.rb"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def show_readme
|
|
37
|
+
readme "README" if behavior == :invoke
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|