Class: OpenC3::WebSocketApi
- Defined in:
- lib/openc3/script/web_socket_api.rb
Overview
Base class - Do not use directly
Direct Known Subclasses
Constant Summary collapse
- USER_AGENT =
'OpenC3 / v7 (ruby/openc3/lib/io/web_socket_api)'.freeze
- DEFAULT_OPTIONS =
Options every websocket api accepts, and their defaults. Subclasses forward **options rather than restating these.
{ write_timeout: 10.0, read_timeout: 10.0, connect_timeout: 5.0, authentication: nil, }.freeze
Class Method Summary collapse
-
.cable_url(env_prefix:, default_hostname:, default_port:, path:) ⇒ Object
Build a cable URL from the standard OPENC3 environment variable quartet:
_SCHEMA, _HOSTNAME, _CABLE_PORT, _PORT.
Instance Method Summary collapse
-
#check_protocol_frame(json_hash) ⇒ Object
Apply the protocol rules shared by #read and #wait_for_subscribed.
-
#connect ⇒ Object
Connect to the websocket with authorization in query params.
-
#connected? ⇒ Boolean
Are we connected?.
-
#disconnect ⇒ Object
Disconnect from the websocket and attempt to send unsubscribe message.
-
#generate_auth ⇒ Object
Generate the appropriate token for OpenC3.
-
#initialize(url:, scope: $openc3_scope, **options, &block) ⇒ WebSocketApi
constructor
Create the WebsocketApi object.
-
#parse_message(message) ⇒ Object
Parse a server frame.
-
#read(ignore_protocol_messages: true, timeout: nil) ⇒ Object
Read the next message with json parsing, filtering, and timeout support.
-
#read_message ⇒ Object
Read the next message without filtering / parsing.
-
#subscribe ⇒ Object
Will subscribe to the channel based on @identifier.
-
#unsubscribe ⇒ Object
Will unsubscribe to the channel based on @identifier.
-
#wait_for_subscribed ⇒ Object
Block until the server confirms the subscription.
-
#write(data) ⇒ Object
General write to the websocket.
-
#write_action(data_hash) ⇒ Object
Send an ActionCable command.
-
#write_command(command, data_hash = nil) ⇒ Object
Write an ActionCable command frame for the current @identifier.
Constructor Details
#initialize(url:, scope: $openc3_scope, **options, &block) ⇒ WebSocketApi
Create the WebsocketApi object. If a block is given will automatically connect/disconnect
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/openc3/script/web_socket_api.rb', line 51 def initialize(url:, scope: $openc3_scope, **, &block) # Restore the arity checking that explicit keyword arguments used to give # us, so a typo'd option is an error rather than a silently ignored value unknown = .keys - DEFAULT_OPTIONS.keys raise ArgumentError, "unknown keyword#{'s' if unknown.length > 1}: #{unknown.join(', ')}" unless unknown.empty? = DEFAULT_OPTIONS.merge() # $openc3_scope is only set inside the Script Runner / microservice # environment. Fall back to OPENC3_SCOPE (mirrors the Python client) so a # bare `ruby script.rb` doesn't send a nil scope that the server rejects. @scope = scope || ENV.fetch('OPENC3_SCOPE', 'DEFAULT') @authentication = [:authentication] || generate_auth() @url = url @write_timeout = [:write_timeout] @read_timeout = [:read_timeout] @connect_timeout = [:connect_timeout] @subscribed = false if block_given? begin connect() yield self ensure disconnect() end end end |
Class Method Details
.cable_url(env_prefix:, default_hostname:, default_port:, path:) ⇒ Object
Build a cable URL from the standard OPENC3 environment variable quartet:
34 35 36 37 38 39 40 41 42 43 44 |
# File 'lib/openc3/script/web_socket_api.rb', line 34 def self.cable_url(env_prefix:, default_hostname:, default_port:, path:) schema = ENV.fetch("#{env_prefix}_SCHEMA", 'http') # Normalize to the websocket schemes (mirrors the Python client). The # websocket gem accepts http/https too, but ws/wss is what the URL # actually is and Python's websockets library rejects anything else. schema = 'ws' if schema == 'http' schema = 'wss' if schema == 'https' hostname = ENV.fetch("#{env_prefix}_HOSTNAME", nil) || (ENV['OPENC3_DEVEL'] ? '127.0.0.1' : default_hostname) port = (ENV.fetch("#{env_prefix}_CABLE_PORT", nil) || ENV.fetch("#{env_prefix}_PORT", default_port)).to_i return "#{schema}://#{hostname}:#{port}#{path}" end |
Instance Method Details
#check_protocol_frame(json_hash) ⇒ Object
Apply the protocol rules shared by #read and #wait_for_subscribed
233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/openc3/script/web_socket_api.rb', line 233 def check_protocol_frame(json_hash) case json_hash['type'] when 'reject_subscription' raise "Subscription Rejected" when 'disconnect' # Any other disconnect reason is not fatal; the caller keeps reading raise "Unauthorized" if json_hash['reason'] == 'unauthorized' else # ping, welcome, confirm_subscription and anything new the server adds # are informational only nil end end |
#connect ⇒ Object
Connect to the websocket with authorization in query params
176 177 178 179 180 181 182 183 184 185 |
# File 'lib/openc3/script/web_socket_api.rb', line 176 def connect disconnect() final_url = @url + "?scope=#{@scope}" @stream = WebSocketClientStream.new(final_url, @write_timeout, @read_timeout, @connect_timeout) @stream.headers = { 'Sec-WebSocket-Protocol' => 'actioncable-v1-json, actioncable-unsupported', 'User-Agent' => USER_AGENT } @stream.connect end |
#connected? ⇒ Boolean
Are we connected?
188 189 190 191 192 193 194 |
# File 'lib/openc3/script/web_socket_api.rb', line 188 def connected? if @stream @stream.connected? else false end end |
#disconnect ⇒ Object
Disconnect from the websocket and attempt to send unsubscribe message
197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 |
# File 'lib/openc3/script/web_socket_api.rb', line 197 def disconnect if connected?() begin unsubscribe() rescue # Oh well, we tried end # unsubscribe only clears this after a successful write. The stream is # being closed regardless, so it cannot remain subscribed. @subscribed = false @stream.disconnect else @subscribed = false end end |
#generate_auth ⇒ Object
Generate the appropriate token for OpenC3
248 249 250 251 252 253 254 255 256 257 258 |
# File 'lib/openc3/script/web_socket_api.rb', line 248 def generate_auth if ENV['OPENC3_API_TOKEN'].nil? and ENV['OPENC3_API_USER'].nil? if ENV['OPENC3_API_PASSWORD'] return OpenC3Authentication.new() else raise "Environment Variables Not Set for Authentication" end else return OpenC3KeycloakAuthentication.new(ENV['OPENC3_KEYCLOAK_URL']) end end |
#parse_message(message) ⇒ Object
Parse a server frame. Kept in one place so the JSON options cannot drift between the two readers.
228 229 230 |
# File 'lib/openc3/script/web_socket_api.rb', line 228 def () return JSON.parse(, allow_nan: true, create_additions: true) end |
#read(ignore_protocol_messages: true, timeout: nil) ⇒ Object
Read the next message with json parsing, filtering, and timeout support
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/openc3/script/web_socket_api.rb', line 85 def read(ignore_protocol_messages: true, timeout: nil) start_time = Time.now while true = () # Empty string is a normal end-of-stream signal when ActionCable / anycable-go # closes the WS. Treat it the same as nil so consumer `while (resp = api.read)` # loops exit cleanly instead of hitting JSON::ParserError on JSON.parse(""). return nil if .nil? || .empty? json_hash = () if type = json_hash['type'] if type # ping, welcome, confirm_subscription, reject_subscription, disconnect check_protocol_frame(json_hash) if timeout end_time = Time.now if (end_time - start_time) > timeout raise Timeout::Error, "No Data Timeout" end end if defined? RunningScript and RunningScript.instance raise StopScript if RunningScript.instance.stop? end next end end return json_hash['message'] end end |
#read_message ⇒ Object
Read the next message without filtering / parsing
79 80 81 82 |
# File 'lib/openc3/script/web_socket_api.rb', line 79 def subscribe() return @stream.read end |
#subscribe ⇒ Object
Will subscribe to the channel based on @identifier
116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/openc3/script/web_socket_api.rb', line 116 def subscribe unless @subscribed # Token is part of the identifier so it surfaces as params[:token] in # ApplicationCable::Channel#authenticate_subscription! — ActionCable # ignores `data` on `subscribe` commands. @identifier['token'] = @authentication.token(include_bearer: false) write_command('subscribe') @subscribed = true wait_for_subscribed() end end |
#unsubscribe ⇒ Object
Will unsubscribe to the channel based on @identifier
150 151 152 153 154 155 |
# File 'lib/openc3/script/web_socket_api.rb', line 150 def unsubscribe if @subscribed write_command('unsubscribe') @subscribed = false end end |
#wait_for_subscribed ⇒ Object
Block until the server confirms the subscription. ActionCable / anycable-go process 'subscribe' and 'message' commands as independent RPCs, so an action (add/remove) written immediately after subscribe can reach StreamingChannel#add before the subscription's broadcaster exists, where it is silently dropped (a no-op) and no data ever streams. Waiting for confirm_subscription guarantees the broadcaster is ready before any action is written.
135 136 137 138 139 140 141 142 143 144 145 146 147 |
# File 'lib/openc3/script/web_socket_api.rb', line 135 def wait_for_subscribed while true = @stream.read # Unlike #read, end-of-stream is fatal here rather than a nil return: # a socket that closes mid-handshake leaves nothing to carry on with. raise "WebSocket closed before subscription was confirmed" if .nil? || .empty? json_hash = () check_protocol_frame(json_hash) # Ignore welcome / ping and keep waiting for confirmation return if json_hash['type'] == 'confirm_subscription' end end |
#write(data) ⇒ Object
General write to the websocket
170 171 172 173 |
# File 'lib/openc3/script/web_socket_api.rb', line 170 def write(data) subscribe() @stream.write(data) end |
#write_action(data_hash) ⇒ Object
Send an ActionCable command
158 159 160 161 162 163 164 165 166 167 |
# File 'lib/openc3/script/web_socket_api.rb', line 158 def write_action(data_hash) # Subscribe first so the token is present in @identifier before we # serialize it below. ActionCable matches a 'message' command to its # subscription by the exact identifier string; if subscribe() injected the # token only afterward, the message identifier (no token) would not match # the subscription identifier (with token) and the server would silently # ignore the action. subscribe() write_command('message', data_hash) end |
#write_command(command, data_hash = nil) ⇒ Object
Write an ActionCable command frame for the current @identifier. Writes straight to the stream because the callers have already subscribed (and subscribe itself must not recurse through write).
218 219 220 221 222 223 224 |
# File 'lib/openc3/script/web_socket_api.rb', line 218 def write_command(command, data_hash = nil) json_hash = {} json_hash['command'] = command json_hash['identifier'] = JSON.generate(@identifier, allow_nan: true) json_hash['data'] = JSON.generate(data_hash, allow_nan: true) if data_hash @stream.write(JSON.generate(json_hash, allow_nan: true)) end |