ocpp-rails 0.3.0 → 0.4.0
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 +4 -4
- data/README.md +28 -6
- data/app/services/ocpp/rails/raw_socket/connection.rb +181 -0
- data/app/services/ocpp/rails/raw_socket/endpoint.rb +135 -0
- data/app/services/ocpp/rails/raw_socket/handshake.rb +78 -0
- data/app/services/ocpp/rails/raw_socket/registry.rb +52 -0
- data/config/routes.rb +17 -3
- data/lib/ocpp/rails/version.rb +1 -1
- data/lib/ocpp/rails.rb +27 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec3b88d04d4b69372c6ca9dadf03a43053e54788a79eef1374a2dbec1992ef45
|
|
4
|
+
data.tar.gz: e4f9453a7e00727790f1af2b2998a8f177e93d75036c9870e96f7b6dee0ee90e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fb468229c8aad08762333d4b55693bb6d1fdd3934fb9b36777380dd4d923499a76d28d8dd4a02ae089891e54ccaef0d94e5354be1e2cd4a37ed8d91a830a1405
|
|
7
|
+
data.tar.gz: '0079e806237f2e081ef026ca594ba9d4a9f49793aa065be8cb4d1a5b288357b48853b60f084bd24096d6ee400e8060d57d7187916a49859c5d015f7cd3ebd3af'
|
data/README.md
CHANGED
|
@@ -138,19 +138,41 @@ Ocpp::Rails.setup do |config|
|
|
|
138
138
|
end
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
#### Transports
|
|
142
|
+
|
|
143
|
+
`config.transport` selects how charge points reach the engine:
|
|
144
|
+
|
|
145
|
+
- **`:raw`** — native **bare OCPP-J** over a plain WebSocket, which is what
|
|
146
|
+
real/commercial charge points speak. Stations connect to:
|
|
147
|
+
```
|
|
148
|
+
ws://your-server:3000/ocpp/<charge-point-identifier>
|
|
149
|
+
```
|
|
150
|
+
The identity is the last path segment; frames are the bare OCPP arrays
|
|
151
|
+
(`[2,"id","Action",{…}]`) with subprotocol `ocpp1.6`.
|
|
152
|
+
- **`:action_cable`** (default) — OCPP-J wrapped in the ActionCable JSON protocol.
|
|
153
|
+
Stations connect to `ws://your-server:3000/ocpp/cable` and must speak the
|
|
154
|
+
ActionCable subscribe/`message` handshake (a generic OCPP station cannot; this
|
|
155
|
+
is aimed at ActionCable-aware clients/simulators). Kept as the default for
|
|
156
|
+
backwards compatibility.
|
|
157
|
+
- **`:both`** — mount both endpoints side by side (useful while migrating).
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
Ocpp::Rails.setup do |config|
|
|
161
|
+
config.transport = :raw # accept real OCPP-J wallboxes directly
|
|
162
|
+
end
|
|
144
163
|
```
|
|
145
164
|
|
|
146
|
-
|
|
147
|
-
(OCPP-J Security Profile 1, enabled by default
|
|
148
|
-
credential first:
|
|
165
|
+
Both transports authenticate identically: HTTP Basic Auth on the WebSocket
|
|
166
|
+
upgrade (OCPP-J Security Profile 1, enabled by default; the Basic username must
|
|
167
|
+
equal the station identity). Provision a per-station credential first:
|
|
149
168
|
|
|
150
169
|
```ruby
|
|
151
170
|
charge_point.update!(auth_password: SecureRandom.base58(32))
|
|
152
171
|
```
|
|
153
172
|
|
|
173
|
+
For internet-facing deployments use TLS (`wss://`, Security Profile 2),
|
|
174
|
+
terminated by a reverse proxy in front of the app.
|
|
175
|
+
|
|
154
176
|
For detailed setup instructions, see the [Getting Started Guide](docs/getting-started.md)
|
|
155
177
|
and the [Security Guide](docs/security.md).
|
|
156
178
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
module Ocpp
|
|
2
|
+
module Rails
|
|
3
|
+
module RawSocket
|
|
4
|
+
# One live raw OCPP-J station connection. It wires a websocket driver to the
|
|
5
|
+
# same transport-agnostic core the ActionCable channel uses, so the wire
|
|
6
|
+
# format changes but nothing downstream does:
|
|
7
|
+
#
|
|
8
|
+
# inbound station frame ──▶ MessageHandler (parse, route, hooks, audit)
|
|
9
|
+
# outbound MessageHandler / remote-control jobs ──▶ ChargePointChannel
|
|
10
|
+
# .broadcast_to(cp, {message:}) ──▶ ActionCable pub/sub ──▶ this
|
|
11
|
+
# Connection's subscription ──▶ driver.text down the socket
|
|
12
|
+
#
|
|
13
|
+
# Reusing the existing broadcast as the outbound bus means the 10 producer
|
|
14
|
+
# sites (MessageHandler#send_callresult/#send_callerror and every remote
|
|
15
|
+
# command job) are unchanged, and cross-process routing (a job running on a
|
|
16
|
+
# different Puma worker than the socket) comes for free from the pub/sub
|
|
17
|
+
# adapter (async in dev, solid_cable/redis in production).
|
|
18
|
+
#
|
|
19
|
+
# `driver` is any websocket driver (WebSocket::Driver.rack in production, a
|
|
20
|
+
# stub in tests) responding to #on, #text, #ping, #close and, once started,
|
|
21
|
+
# relaying frames via its :message event. This object is transport-only; it
|
|
22
|
+
# never parses OCPP (Protocol/MessageHandler do).
|
|
23
|
+
class Connection
|
|
24
|
+
attr_reader :charge_point
|
|
25
|
+
|
|
26
|
+
def initialize(charge_point:, driver:, io: nil, logger: ::Rails.logger, pubsub: nil)
|
|
27
|
+
@charge_point = charge_point
|
|
28
|
+
@driver = driver
|
|
29
|
+
@io = io
|
|
30
|
+
@logger = logger
|
|
31
|
+
@pubsub = pubsub
|
|
32
|
+
@write_mutex = Mutex.new
|
|
33
|
+
@closed = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Wire driver callbacks, displace any prior socket for this station, mark
|
|
37
|
+
# it connected, and start relaying server->station frames. The caller
|
|
38
|
+
# (endpoint) still owns writing the 101 (driver.start) and the read loop.
|
|
39
|
+
def open
|
|
40
|
+
@driver.on(:message) { |event| handle_inbound(event.data) }
|
|
41
|
+
@driver.on(:close) { close }
|
|
42
|
+
@driver.on(:error) { |event| log_error("driver error", event) }
|
|
43
|
+
|
|
44
|
+
displace_previous
|
|
45
|
+
::Rails.application.executor.wrap { mark_connected }
|
|
46
|
+
subscribe_outbound
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Feed one inbound OCPP-J frame (a JSON string) to the shared handler,
|
|
51
|
+
# dropping it if the station is over its per-minute message budget — the
|
|
52
|
+
# same guard, in the same place, as ChargePointChannel#receive.
|
|
53
|
+
def handle_inbound(text)
|
|
54
|
+
return if @closed
|
|
55
|
+
|
|
56
|
+
unless Ocpp::Rails.message_rate_limiter.allow?(@charge_point.identifier)
|
|
57
|
+
@logger.warn("[OCPP][security] #{ident}: message rate limit exceeded, dropping message")
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
::Rails.application.executor.wrap do
|
|
62
|
+
MessageHandler.new(@charge_point, text).process
|
|
63
|
+
end
|
|
64
|
+
rescue => e
|
|
65
|
+
log_error("error handling inbound message", e)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Relay a frame published on this station's ActionCable broadcasting down
|
|
69
|
+
# the socket. `encoded` is what the pub/sub adapter carries: the JSON
|
|
70
|
+
# dump of {message: "<ocpp-json>"} that broadcast_to produced.
|
|
71
|
+
def relay(encoded)
|
|
72
|
+
return if @closed
|
|
73
|
+
|
|
74
|
+
frame = JSON.parse(encoded)["message"]
|
|
75
|
+
write(frame) if frame
|
|
76
|
+
rescue => e
|
|
77
|
+
log_error("error relaying outbound frame", e)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def write(frame)
|
|
81
|
+
@write_mutex.synchronize { @driver.text(frame) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def ping
|
|
85
|
+
@write_mutex.synchronize { @driver.ping }
|
|
86
|
+
rescue => e
|
|
87
|
+
log_error("ping failed", e)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Idempotent teardown. Safe to call from the driver's :close event, the
|
|
91
|
+
# endpoint reader thread's ensure, or a displacing reconnect.
|
|
92
|
+
def close
|
|
93
|
+
return if @closed
|
|
94
|
+
|
|
95
|
+
@closed = true
|
|
96
|
+
unsubscribe_outbound
|
|
97
|
+
Registry.remove(@charge_point.identifier, self)
|
|
98
|
+
::Rails.application.executor.wrap { mark_disconnected }
|
|
99
|
+
close_io
|
|
100
|
+
@driver.close
|
|
101
|
+
rescue => e
|
|
102
|
+
log_error("error during teardown", e)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def closed?
|
|
106
|
+
@closed
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def displace_previous
|
|
112
|
+
previous = Registry.register(@charge_point.identifier, self)
|
|
113
|
+
return if previous.nil? || previous.equal?(self)
|
|
114
|
+
|
|
115
|
+
@logger.info("[OCPP][raw] #{ident}: superseding previous connection")
|
|
116
|
+
previous.close
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def subscribe_outbound
|
|
120
|
+
@broadcasting = ChargePointChannel.broadcasting_for(@charge_point)
|
|
121
|
+
@relay = ->(encoded) { relay(encoded) }
|
|
122
|
+
pubsub.subscribe(@broadcasting, @relay)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def unsubscribe_outbound
|
|
126
|
+
pubsub.unsubscribe(@broadcasting, @relay) if @broadcasting && @relay
|
|
127
|
+
rescue => e
|
|
128
|
+
log_error("unsubscribe failed", e)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def pubsub
|
|
132
|
+
@pubsub ||= ActionCable.server.pubsub
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def mark_connected
|
|
136
|
+
was = @charge_point.connected
|
|
137
|
+
@charge_point.update(connected: true, last_heartbeat_at: Time.current)
|
|
138
|
+
log_connection_change(was, true)
|
|
139
|
+
@logger.info("[OCPP][raw] #{ident}: connected")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def mark_disconnected
|
|
143
|
+
was = @charge_point.connected
|
|
144
|
+
@charge_point.disconnect!
|
|
145
|
+
log_connection_change(was, false)
|
|
146
|
+
@logger.info("[OCPP][raw] #{ident}: disconnected")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def log_connection_change(old_connected, new_connected)
|
|
150
|
+
return if old_connected == new_connected
|
|
151
|
+
|
|
152
|
+
StateChange.create!(
|
|
153
|
+
charge_point: @charge_point,
|
|
154
|
+
change_type: "connection",
|
|
155
|
+
connector_id: nil,
|
|
156
|
+
old_value: old_connected.to_s,
|
|
157
|
+
new_value: new_connected.to_s,
|
|
158
|
+
metadata: { source: "raw_socket" }
|
|
159
|
+
)
|
|
160
|
+
rescue => e
|
|
161
|
+
log_error("failed to log state change", e)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def close_io
|
|
165
|
+
@io.close if @io && !@io.closed?
|
|
166
|
+
rescue => e
|
|
167
|
+
log_error("io close failed", e)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def ident
|
|
171
|
+
"ChargePoint #{@charge_point.identifier}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def log_error(context, error)
|
|
175
|
+
message = error.respond_to?(:message) ? error.message : error.to_s
|
|
176
|
+
@logger.error("[OCPP][raw] #{ident}: #{context}: #{message}")
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
require "websocket/driver"
|
|
2
|
+
require "socket"
|
|
3
|
+
|
|
4
|
+
module Ocpp
|
|
5
|
+
module Rails
|
|
6
|
+
module RawSocket
|
|
7
|
+
# Rack app that terminates native OCPP-J WebSocket connections — what a real
|
|
8
|
+
# charge point speaks (bare `[2,"id","Action",{}]` frames over a plain
|
|
9
|
+
# WebSocket, subprotocol "ocpp1.6", identity in the URL path), with no
|
|
10
|
+
# ActionCable subscription handshake.
|
|
11
|
+
#
|
|
12
|
+
# It authenticates from the Rack env *before* taking the socket, then
|
|
13
|
+
# full-hijacks (Rack `rack.hijack`, supported by Puma), completes the
|
|
14
|
+
# WebSocket upgrade with websocket-driver, and hands the socket to a
|
|
15
|
+
# Connection that bridges to the shared OCPP core. One reader thread per
|
|
16
|
+
# socket feeds inbound bytes to the driver; outbound frames arrive via the
|
|
17
|
+
# Connection's pub/sub subscription.
|
|
18
|
+
#
|
|
19
|
+
# Mount it from the engine when `config.transport` includes :raw.
|
|
20
|
+
class Endpoint
|
|
21
|
+
READ_CHUNK = 16_384
|
|
22
|
+
|
|
23
|
+
def call(env)
|
|
24
|
+
return not_websocket unless WebSocket::Driver.websocket?(env)
|
|
25
|
+
return no_hijack unless env["rack.hijack"]
|
|
26
|
+
|
|
27
|
+
decision = Handshake.new(env).call
|
|
28
|
+
unless decision.accepted?
|
|
29
|
+
log_reject(decision)
|
|
30
|
+
return unauthorized
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
accept(env, decision)
|
|
34
|
+
# Rack full-hijack sentinel: the server must not write a response.
|
|
35
|
+
[ -1, {}, [] ]
|
|
36
|
+
rescue => e
|
|
37
|
+
::Rails.logger.error("[OCPP][raw] endpoint error: #{e.class}: #{e.message}")
|
|
38
|
+
internal_error
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def accept(env, decision)
|
|
44
|
+
env["rack.hijack"].call
|
|
45
|
+
io = env["rack.hijack_io"]
|
|
46
|
+
enable_tcp_keepalive(io)
|
|
47
|
+
|
|
48
|
+
driver = WebSocket::Driver.rack(
|
|
49
|
+
RackSocket.new(env, io),
|
|
50
|
+
protocols: Ocpp::Rails.configuration.websocket_subprotocols,
|
|
51
|
+
max_length: Ocpp::Rails.configuration.raw_socket_max_frame_bytes
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
connection = Connection.new(charge_point: decision.charge_point, driver: driver, io: io)
|
|
55
|
+
connection.open
|
|
56
|
+
driver.start # writes the 101 handshake, echoing the negotiated Sec-WebSocket-Protocol
|
|
57
|
+
spawn_reader(io, driver, connection)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# One blocking reader per socket (Puma's threaded model). websocket-driver
|
|
61
|
+
# parses frames and fires the Connection's :message callback synchronously
|
|
62
|
+
# on this thread; teardown is funnelled through Connection#close so it runs
|
|
63
|
+
# exactly once whether the peer closed, the socket errored, or the driver
|
|
64
|
+
# emitted :close.
|
|
65
|
+
def spawn_reader(io, driver, connection)
|
|
66
|
+
Thread.new do
|
|
67
|
+
Thread.current.name = "ocpp-raw-#{connection.charge_point.identifier}"
|
|
68
|
+
begin
|
|
69
|
+
until connection.closed?
|
|
70
|
+
driver.parse(io.readpartial(READ_CHUNK))
|
|
71
|
+
end
|
|
72
|
+
rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE
|
|
73
|
+
# peer went away / socket closed — normal disconnect
|
|
74
|
+
rescue => e
|
|
75
|
+
::Rails.logger.error(
|
|
76
|
+
"[OCPP][raw] reader error for #{connection.charge_point.identifier}: #{e.message}"
|
|
77
|
+
)
|
|
78
|
+
ensure
|
|
79
|
+
connection.close
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Detect a silently-dropped peer (no FIN) so a half-open socket doesn't
|
|
85
|
+
# pin the station "connected" forever; OCPP Heartbeat covers the rest.
|
|
86
|
+
def enable_tcp_keepalive(io)
|
|
87
|
+
io.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true) if io.respond_to?(:setsockopt)
|
|
88
|
+
rescue StandardError
|
|
89
|
+
# non-fatal; some IO objects (e.g. in tests) don't support socket opts
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def not_websocket
|
|
93
|
+
[ 426, { "content-type" => "text/plain", "connection" => "upgrade", "upgrade" => "websocket" },
|
|
94
|
+
[ "Upgrade required" ] ]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def no_hijack
|
|
98
|
+
[ 501, { "content-type" => "text/plain" }, [ "WebSocket hijacking not supported" ] ]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Security-Profile-1 challenge, so a station may (re)send Basic credentials.
|
|
102
|
+
def unauthorized
|
|
103
|
+
[ 401, { "content-type" => "text/plain", "www-authenticate" => %(Basic realm="OCPP") },
|
|
104
|
+
[ "Unauthorized" ] ]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def internal_error
|
|
108
|
+
[ 500, { "content-type" => "text/plain" }, [ "Internal Server Error" ] ]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def log_reject(decision)
|
|
112
|
+
::Rails.logger.warn(
|
|
113
|
+
"[OCPP][security] raw OCPP-J upgrade rejected for #{decision.identifier.inspect}: #{decision.failure}"
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Adapter presented to WebSocket::Driver.rack: the driver reads the
|
|
118
|
+
# Sec-WebSocket-* handshake headers from #env and writes the 101 response
|
|
119
|
+
# and subsequent frames through #write to the hijacked socket.
|
|
120
|
+
class RackSocket
|
|
121
|
+
attr_reader :env
|
|
122
|
+
|
|
123
|
+
def initialize(env, io)
|
|
124
|
+
@env = env
|
|
125
|
+
@io = io
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def write(data)
|
|
129
|
+
@io.write(data)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
|
|
3
|
+
module Ocpp
|
|
4
|
+
module Rails
|
|
5
|
+
module RawSocket
|
|
6
|
+
# Decides whether a raw OCPP-J WebSocket upgrade should be accepted, purely
|
|
7
|
+
# from the Rack env — no socket I/O — so it is unit-testable in isolation
|
|
8
|
+
# and identical whether the transport hijacks under Puma or is driven by a
|
|
9
|
+
# test harness.
|
|
10
|
+
#
|
|
11
|
+
# OCPP-J carries the charge-point identity in the URL *path* (unlike the
|
|
12
|
+
# ActionCable channel, which reads it from the subscribe command) and the
|
|
13
|
+
# Security-Profile-1 credential in the HTTP Basic `Authorization` header of
|
|
14
|
+
# the upgrade. This mirrors `ChargePointChannel#subscribed` — connection
|
|
15
|
+
# rate limit, then `StationAuthenticator` — so both transports authenticate
|
|
16
|
+
# a station identically.
|
|
17
|
+
class Handshake
|
|
18
|
+
Result = Struct.new(:charge_point, :identifier, :subprotocol, :failure, keyword_init: true) do
|
|
19
|
+
def accepted?
|
|
20
|
+
failure.nil?
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(env, subprotocols: Ocpp::Rails.configuration.websocket_subprotocols)
|
|
25
|
+
@env = env
|
|
26
|
+
@subprotocols = Array(subprotocols)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
identifier = self.class.identifier_from_path(@env["PATH_INFO"])
|
|
31
|
+
return reject(identifier, :missing_identifier) if identifier.nil? || identifier.empty?
|
|
32
|
+
|
|
33
|
+
unless Ocpp::Rails.connection_rate_limiter.allow?(identifier)
|
|
34
|
+
return reject(identifier, :rate_limited)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
result = StationAuthenticator.authenticate(
|
|
38
|
+
identifier: identifier,
|
|
39
|
+
authorization_header: @env["HTTP_AUTHORIZATION"]
|
|
40
|
+
)
|
|
41
|
+
return reject(identifier, result.failure) unless result.success?
|
|
42
|
+
|
|
43
|
+
Result.new(
|
|
44
|
+
charge_point: result.charge_point,
|
|
45
|
+
identifier: identifier,
|
|
46
|
+
subprotocol: negotiated_subprotocol,
|
|
47
|
+
failure: nil
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The OCPP identity is the last non-empty path segment, URL-decoded:
|
|
52
|
+
# "/ocpp/CP-1" and vendor variants like "/ocpp/steve/CP-1" both yield
|
|
53
|
+
# "CP-1". (Under the mounted engine the endpoint sees the engine-relative
|
|
54
|
+
# path, e.g. "/CP-1".)
|
|
55
|
+
def self.identifier_from_path(path)
|
|
56
|
+
return nil if path.nil?
|
|
57
|
+
|
|
58
|
+
segment = path.split("/").reject(&:empty?).last
|
|
59
|
+
segment && CGI.unescape(segment)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# The actual Sec-WebSocket-Protocol echo is performed by the websocket
|
|
65
|
+
# driver from the same list; this is the value we expect it to pick, kept
|
|
66
|
+
# on the Result for logging/inspection. nil when the station offered none.
|
|
67
|
+
def negotiated_subprotocol
|
|
68
|
+
offered = (@env["HTTP_SEC_WEBSOCKET_PROTOCOL"] || "").split(/ *, */)
|
|
69
|
+
(@subprotocols & offered).first
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def reject(identifier, failure)
|
|
73
|
+
Result.new(identifier: identifier, failure: failure)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Ocpp
|
|
2
|
+
module Rails
|
|
3
|
+
module RawSocket
|
|
4
|
+
# Process-local map of the live raw connections this process owns, keyed by
|
|
5
|
+
# charge-point identifier. Used to displace a stale socket when a station
|
|
6
|
+
# reconnects (OCPP allows only one live connection per station) and to see
|
|
7
|
+
# how many stations a process holds. NOT used for message delivery — that
|
|
8
|
+
# rides the ActionCable pub/sub bus (see Connection), which already routes
|
|
9
|
+
# across processes.
|
|
10
|
+
#
|
|
11
|
+
# State lives in class ivars, so in development each code reload starts
|
|
12
|
+
# empty (dev drops sockets on reload anyway); in production the process is
|
|
13
|
+
# eager-loaded and never reloaded, so entries persist for the socket's life.
|
|
14
|
+
class Registry
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
@connections = {}
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Record `connection` as the current owner of `identifier`, returning
|
|
20
|
+
# the connection it replaced (if any) so the caller can close it.
|
|
21
|
+
def register(identifier, connection)
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
previous = @connections[identifier]
|
|
24
|
+
@connections[identifier] = connection
|
|
25
|
+
previous
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Drop `connection` only if it is still the current owner (a newer
|
|
30
|
+
# reconnect must not be evicted by an older socket's teardown).
|
|
31
|
+
def remove(identifier, connection)
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
@connections.delete(identifier) if @connections[identifier].equal?(connection)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def [](identifier)
|
|
38
|
+
@mutex.synchronize { @connections[identifier] }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def size
|
|
42
|
+
@mutex.synchronize { @connections.size }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def clear!
|
|
46
|
+
@mutex.synchronize { @connections = {} }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/config/routes.rb
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
Ocpp::Rails::Engine.routes.draw do
|
|
2
|
-
# OCPP
|
|
3
|
-
#
|
|
4
|
-
|
|
2
|
+
# OCPP transport(s), selected by `config.transport` (see Ocpp::Rails::Configuration):
|
|
3
|
+
#
|
|
4
|
+
# :action_cable (default) — OCPP-J wrapped in the ActionCable JSON protocol.
|
|
5
|
+
# Charge points connect to: ws://your-host/ocpp/cable
|
|
6
|
+
# :raw — native bare OCPP-J over a plain WebSocket, what real stations speak.
|
|
7
|
+
# Charge points connect to: ws://your-host/ocpp/<charge-point-identifier>
|
|
8
|
+
# :both — mount both (migration).
|
|
9
|
+
#
|
|
10
|
+
# /cable is declared first so it keeps winning over the greedy raw mount at "/".
|
|
11
|
+
if Ocpp::Rails.transport_enabled?(:action_cable)
|
|
12
|
+
mount ActionCable.server => "/cable"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
if Ocpp::Rails.transport_enabled?(:raw)
|
|
16
|
+
mount Ocpp::Rails::RawSocket::Endpoint.new => Ocpp::Rails.configuration.raw_socket_path,
|
|
17
|
+
as: :ocpp_raw_socket
|
|
18
|
+
end
|
|
5
19
|
end
|
data/lib/ocpp/rails/version.rb
CHANGED
data/lib/ocpp/rails.rb
CHANGED
|
@@ -19,6 +19,15 @@ module Ocpp
|
|
|
19
19
|
configuration.supported_versions
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# Whether a given charge-point transport is active. `transport` is one of
|
|
23
|
+
# :action_cable (default), :raw, or :both — so a deployment can run the
|
|
24
|
+
# legacy ActionCable-wrapped endpoint, the raw OCPP-J endpoint, or both side
|
|
25
|
+
# by side during migration. Consulted when the engine draws its routes.
|
|
26
|
+
def self.transport_enabled?(kind)
|
|
27
|
+
transport = configuration.transport
|
|
28
|
+
transport == kind || transport == :both
|
|
29
|
+
end
|
|
30
|
+
|
|
22
31
|
def self.message_rate_limiter
|
|
23
32
|
@message_rate_limiter ||= RateLimiter.new { configuration.max_messages_per_minute }
|
|
24
33
|
end
|
|
@@ -37,7 +46,8 @@ module Ocpp
|
|
|
37
46
|
:state_change_hooks, :state_change_retention_days, :state_change_cleanup_enabled,
|
|
38
47
|
:authorization_hooks, :authorization_retention_days, :authorization_cleanup_enabled,
|
|
39
48
|
:session_hooks, :implausible_energy_jump_wh, :authentication_mode,
|
|
40
|
-
:max_messages_per_minute, :max_connection_attempts_per_minute
|
|
49
|
+
:max_messages_per_minute, :max_connection_attempts_per_minute,
|
|
50
|
+
:transport, :raw_socket_path, :websocket_subprotocols, :raw_socket_max_frame_bytes
|
|
41
51
|
|
|
42
52
|
def initialize
|
|
43
53
|
@ocpp_version = "1.6"
|
|
@@ -63,6 +73,22 @@ module Ocpp
|
|
|
63
73
|
# nil disables the respective check.
|
|
64
74
|
@max_messages_per_minute = 300
|
|
65
75
|
@max_connection_attempts_per_minute = 12
|
|
76
|
+
# Charge-point transport. :action_cable (default; ActionCable-wrapped
|
|
77
|
+
# OCPP-J, backwards compatible) | :raw (native bare OCPP-J over a plain
|
|
78
|
+
# WebSocket, what real stations speak) | :both (run both endpoints).
|
|
79
|
+
@transport = :action_cable
|
|
80
|
+
# Where the raw OCPP-J endpoint is mounted *within the engine*. With the
|
|
81
|
+
# host app mounting the engine at "/ocpp", the default "/" makes a station
|
|
82
|
+
# connect to ws://host/ocpp/<identifier> (the trailing path segment is the
|
|
83
|
+
# OCPP identity). "/cable" stays reserved for the ActionCable endpoint.
|
|
84
|
+
@raw_socket_path = "/"
|
|
85
|
+
# Subprotocols offered back to a raw station, in server-preference order.
|
|
86
|
+
# OCPP 1.6-J stations send "ocpp1.6"; the negotiated value is echoed in
|
|
87
|
+
# the handshake's Sec-WebSocket-Protocol response header.
|
|
88
|
+
@websocket_subprotocols = [ "ocpp1.6", "ocpp1.6j" ]
|
|
89
|
+
# Max inbound WebSocket message size on the raw endpoint (bytes). OCPP-J
|
|
90
|
+
# messages are small; oversized frames are a DoS vector and are refused.
|
|
91
|
+
@raw_socket_max_frame_bytes = 64 * 1024
|
|
66
92
|
end
|
|
67
93
|
|
|
68
94
|
def register_state_change_hook(hook)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ocpp-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jakob Sommerhuber
|
|
@@ -143,6 +143,10 @@ files:
|
|
|
143
143
|
- app/services/ocpp/rails/meter_anomaly_detector.rb
|
|
144
144
|
- app/services/ocpp/rails/protocol.rb
|
|
145
145
|
- app/services/ocpp/rails/rate_limiter.rb
|
|
146
|
+
- app/services/ocpp/rails/raw_socket/connection.rb
|
|
147
|
+
- app/services/ocpp/rails/raw_socket/endpoint.rb
|
|
148
|
+
- app/services/ocpp/rails/raw_socket/handshake.rb
|
|
149
|
+
- app/services/ocpp/rails/raw_socket/registry.rb
|
|
146
150
|
- app/services/ocpp/rails/session_hook_manager.rb
|
|
147
151
|
- app/services/ocpp/rails/state_change_hook_manager.rb
|
|
148
152
|
- app/services/ocpp/rails/station_authenticator.rb
|