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,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,5 @@
1
+ Ocpp::Rails::Engine.routes.draw do
2
+ # OCPP WebSocket endpoint via ActionCable
3
+ # Charge points connect to: ws://your-host/ocpp/cable
4
+ mount ActionCable.server => "/cable"
5
+ end
@@ -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,5 @@
1
+ class MakeChargingSessionIdNullableInMeterValues < ActiveRecord::Migration[8.0]
2
+ def change
3
+ change_column_null :ocpp_meter_values, :charging_session_id, true
4
+ end
5
+ 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,6 @@
1
+ class AddTimestampProvenanceToOcppMeterValues < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :ocpp_meter_values, :raw_timestamp, :string
4
+ add_column :ocpp_meter_values, :timestamp_source, :string, default: "station", null: false
5
+ end
6
+ 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,6 @@
1
+ class AddAnomalyFlagsToOcppMeterValues < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :ocpp_meter_values, :flagged, :boolean, default: false, null: false
4
+ add_column :ocpp_meter_values, :flag_reason, :string
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ class AddAuthPasswordDigestToOcppChargePoints < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :ocpp_charge_points, :auth_password_digest, :string
4
+ end
5
+ 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