Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions openc3-cosmos-cmd-tlm-api/app/controllers/queues_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -230,10 +230,30 @@ def exec_command
hazardous = false
token = get_token(username(), scope: params[:scope])
begin
if hazardous
cmd_no_hazardous_check(command_data['value'], queue: false, scope: params[:scope], token: token)
# Support both new format (target_name, cmd_name, cmd_params) and legacy format (value)
if command_data['target_name'] && command_data['cmd_name']
# New format: use 3-parameter cmd() method
if command_data['cmd_params']
cmd_params = JSON.parse(command_data['cmd_params'], allow_nan: true, create_additions: true)
else
cmd_params = {}
end
if hazardous
cmd_no_hazardous_check(command_data['target_name'], command_data['cmd_name'], cmd_params, queue: false, scope: params[:scope], token: token)
else
cmd(command_data['target_name'], command_data['cmd_name'], cmd_params, queue: false, scope: params[:scope], token: token)
end
elsif command_data['value']
# Legacy format: use single string parameter
if hazardous
cmd_no_hazardous_check(command_data['value'], queue: false, scope: params[:scope], token: token)
else
cmd(command_data['value'], queue: false, scope: params[:scope], token: token)
end
else
cmd(command_data['value'], queue: false, scope: params[:scope], token: token)
log_error("Invalid command format in queue: #{command_data}")
render json: { status: 'error', message: "Invalid command format: missing required fields" }, status: 400
return
end
rescue HazardousError => e
# Rescue hazardous error and retry with cmd_no_hazardous_check
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ export default {
commandDescription: '',
paramList: '',
queueName: null,
validateParameter: null,
lastTargetName: '',
lastCommandName: '',
lastParamList: '',
Expand Down Expand Up @@ -364,16 +365,35 @@ export default {
if (command === '') {
return
}
// Parse queue parameter if present (e.g., cmd("...", queue: "Foo") or cmd("...", queue="Foo"))
// Parse queue parameter if present (e.g., cmd("...", queue: "Foo") or cmd("...", queue = "Foo")
// or cmd("...", queue: false) or cmd("...", queue=False)
// Reset queue to null first
this.queueName = null
const queueMatch = command.match(/,\s*queue[:=]\s*"([^"]+)"/)
const queueMatch = command.match(
/,\s*queue(?::|\s*=)\s*(?:"([^"]+)"|([f|F]alse))/, // codespell:ignore
)
if (queueMatch) {
this.queueName = queueMatch[1]
console.log('Queue extracted from history:', this.queueName)
// Remove the queue parameter from the command string
command = command.replace(/,\s*queue[:=]\s*"[^"]+"/, '')
command = command.replace(
/,\s*queue(?::|\s*=)\s*(?:"([^"]+)"|([f|F]alse))/, // codespell:ignore
'',
)
}
// Parse validate parameter if present (e.g., cmd("...", validate: false) or cmd("...", validate=True)
this.validateParameter = null
const validateMatch = command.match(
/,\s*validate(?::|\s*=)\s*(false|true)/i,
)
if (validateMatch) {
this.validateParameter = validateMatch[1]
// Remove the validate parameter from the command string
command = command.replace(
/,\s*validate(?::|\s*=)\s*(false|true)/i,
'',
)
}

// Remove the cmd("") wrapper
let firstQuote = command.indexOf('"')
let lastQuote = command.lastIndexOf('"')
Expand Down Expand Up @@ -562,9 +582,13 @@ export default {
this.displaySendHazardous = true
} else {
let obs
let kwparams = this.disableCommandValidation
? { validate: false }
: {}
let kwparams = {}
if (this.validateParameter !== null) {
kwparams.validate =
this.validateParameter.toLowerCase() === 'true'
} else if (this.disableCommandValidation) {
kwparams.validate = false
}
// Add queue parameter if a queue is selected
if (this.queueName) {
kwparams.queue = this.queueName
Expand Down Expand Up @@ -655,7 +679,12 @@ export default {
this.displaySendHazardous = false
let obs = ''
let cmd = ''
let kwparams = this.disableCommandValidation ? { validate: false } : {}
let kwparams = {}
if (this.validateParameter !== null) {
kwparams.validate = this.validateParameter.toLowerCase() === 'true'
} else if (this.disableCommandValidation) {
kwparams.validate = false
}
// Add queue parameter if a queue is selected
if (this.lastQueueName) {
kwparams.queue = this.lastQueueName
Expand Down Expand Up @@ -785,12 +814,20 @@ export default {
closingParams.push(`queue: "${this.lastQueueName}"`)
}
}
if (this.disableCommandValidation) {
if (this.disableCommandValidation || this.validateParameter !== null) {
const language = AceEditorUtils.getDefaultScriptingLanguage()
if (language === 'python') {
closingParams.push('validate=False')
if (this.validateParameter !== null) {
if (language === 'python') {
closingParams.push(`validate=${this.validateParameter}`)
} else {
closingParams.push(`validate: ${this.validateParameter}`)
}
} else {
closingParams.push('validate: false')
if (language === 'python') {
closingParams.push('validate=False')
} else {
closingParams.push('validate: false')
}
}
}

Expand Down
10 changes: 7 additions & 3 deletions openc3/lib/openc3/api/cmd_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -545,9 +545,13 @@ def _cmd_implementation(method_name, *args, range_check:, hazardous_check:, raw:
queue = ENV['OPENC3_DEFAULT_QUEUE']
end
if queue
# Pull the command out of the script string, e.g. cmd("INST ABORT")
queued = cmd_string.split('("')[1].split('")')[0]
QueueModel.queue_command(queue, command: queued, username: username, scope: scope)
# Pass the command components separately for the queue microservice to use the 3-parameter cmd() method
QueueModel.queue_command(queue,
target_name: target_name,
cmd_name: cmd_name,
cmd_params: cmd_params,
username: username,
scope: scope)
else
CommandTopic.send_command(command, timeout: timeout, scope: scope)
end
Expand Down
6 changes: 6 additions & 0 deletions openc3/lib/openc3/io/json_rpc.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ class String
NON_ASCII_PRINTABLE = /[^\x21-\x7e\s]/
NON_UTF8_PRINTABLE = /[\x00-\x08\x0E-\x1F\x7F]/
def as_json(_options = nil)
# If string is ASCII-8BIT (binary) and has non-ASCII bytes (> 127), encode as binary
# This handles data from hex_to_byte_string and other binary sources
if self.encoding == Encoding::ASCII_8BIT && self.bytes.any? { |b| b > 127 }
return self.to_json_raw_object
end
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was actually important to getting a command like cmd("INST MEMLOAD with DATA 0xDEAD") to work


as_utf8 = self.dup.force_encoding('UTF-8')
if as_utf8.valid_encoding?
if as_utf8 =~ NON_UTF8_PRINTABLE
Expand Down
17 changes: 16 additions & 1 deletion openc3/lib/openc3/microservices/queue_microservice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,22 @@ def process_queued_commands
# OPENC3_DEFAULT_QUEUE is set because commands would be re-queued to the default queue
# NOTE: cmd() via script rescues hazardous errors and calls prompt_for_hazardous()
# but we've overridden it to always return true and go straight to cmd_no_hazardous_check()
cmd(command['value'], queue: false, scope: @scope)

# Support both new format (target_name, cmd_name, cmd_params) and legacy format (command string)
if command['target_name'] && command['cmd_name']
# New format: use 3-parameter cmd() method
if command['cmd_params']
cmd_params = JSON.parse(command['cmd_params'], allow_nan: true, create_additions: true)
else
cmd_params = {}
end
cmd(command['target_name'], command['cmd_name'], cmd_params, queue: false, scope: @scope)
elsif command['value']
# Legacy format: use single string parameter for backwards compatibility
cmd(command['value'], queue: false, scope: @scope)
else
@logger.error "QueueProcessor: Invalid command format, missing required fields"
end
end
rescue StandardError => e
@logger.error "QueueProcessor failed to process command from queue #{@name}\n#{e.message}"
Expand Down
37 changes: 32 additions & 5 deletions openc3/lib/openc3/models/queue_model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
require 'openc3/models/microservice_model'
require 'openc3/topics/queue_topic'
require 'openc3/utilities/logger'
require 'openc3/io/json_rpc'

module OpenC3
class QueueError < StandardError; end
Expand All @@ -44,7 +45,7 @@ def self.all(scope:)
end
# END NOTE

def self.queue_command(name, command:, username:, scope:)
def self.queue_command(name, command: nil, target_name: nil, cmd_name: nil, cmd_params: nil, username:, scope:)
model = get_model(name: name, scope: scope)
raise QueueError, "Queue '#{name}' not found in scope '#{scope}'" unless model

Expand All @@ -55,10 +56,26 @@ def self.queue_command(name, command:, username:, scope:)
else
id = result[0][1].to_f + 1
end
Store.zadd("#{scope}:#{name}", id, { username: username, value: command, timestamp: Time.now.to_nsec_from_epoch }.to_json)

# Build command data with support for both formats
command_data = { username: username, timestamp: Time.now.to_nsec_from_epoch }
if target_name && cmd_name
# New format: store target_name, cmd_name, and cmd_params separately
command_data[:target_name] = target_name
command_data[:cmd_name] = cmd_name
command_data[:cmd_params] = JSON.generate(cmd_params.as_json, allow_nan: true) if cmd_params
elsif command
# Legacy format: store command string for backwards compatibility
command_data[:value] = command
else
raise QueueError, "Must provide either command string or target_name/cmd_name parameters"
end

Store.zadd("#{scope}:#{name}", id, command_data.to_json)
model.notify(kind: 'command')
else
raise QueueError, "Queue '#{name}' is disabled. Command '#{command}' not queued."
error_msg = command || "#{target_name} #{cmd_name}"
raise QueueError, "Queue '#{name}' is disabled. Command '#{error_msg}' not queued."
end
end

Expand Down Expand Up @@ -105,7 +122,12 @@ def notify(kind:)

def insert_command(id, command_data)
if @state == 'DISABLE'
raise QueueError, "Queue '#{@name}' is disabled. Command '#{command_data['value']}' not queued."
if command_data['value']
command_name = command_data['value']
else
command_name = "#{command_data['target_name']} #{command_data['cmd_name']}"
end
raise QueueError, "Queue '#{@name}' is disabled. Command '#{command_name}' not queued."
end

unless id
Expand All @@ -116,6 +138,11 @@ def insert_command(id, command_data)
id = result[0][1].to_f + 1
end
end

# Convert cmd_params values to JSON-safe format if present
if command_data['cmd_params']
command_data['cmd_params'] = JSON.generate(command_data['cmd_params'].as_json, allow_nan: true)
end
Store.zadd("#{@scope}:#{@name}", id, command_data.to_json)
notify(kind: 'command')
end
Expand Down Expand Up @@ -163,7 +190,7 @@ def remove_command(id = nil)
else
score = result[0][1]
Store.zremrangebyscore("#{@scope}:#{@name}", score, score)
command_data = JSON.parse(result[0][0])
command_data = JSON.parse(result[0][0], allow_nan: true)
command_data['id'] = score.to_f
notify(kind: 'command')
return command_data
Expand Down
16 changes: 8 additions & 8 deletions openc3/python/test/interfaces/test_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ def read_interface(self):
self.assertEqual(interface.read_count, 1)
self.assertEqual(interface.bytes_read, 4)
interface.stop_raw_logging()
time.sleep(0.01) # Allow file to be transferred
time.sleep(0.01) # Allow file to be transferred
filename = self.mock_s3.files()[0]
self.assertIn("myinterface_stream_read.bin.gz", filename)
self.assertEqual(self.mock_s3.data(filename), b"\x01\x02\x03\x04")
Expand Down Expand Up @@ -251,7 +251,7 @@ def read_interface(self):
self.assertEqual(interface.read_count, 1)
self.assertEqual(interface.bytes_read, 4)
interface.stop_raw_logging()
time.sleep(0.01) # Allow file to be transferred
time.sleep(0.01) # Allow file to be transferred
# Raw logging is still the original read_data return
filename = self.mock_s3.files()[0]
self.assertIn("myinterface_stream_read.bin.gz", filename)
Expand All @@ -276,7 +276,7 @@ def read_interface(self):
self.assertEqual(interface.read_count, 0)
self.assertEqual(interface.bytes_read, 4)
interface.stop_raw_logging()
time.sleep(0.01) # Allow file to be transferred
time.sleep(0.01) # Allow file to be transferred
filename = self.mock_s3.files()[0]
self.assertIn("myinterface_stream_read.bin.gz", filename)
self.assertEqual(self.mock_s3.data(filename), b"\x01\x02\x03\x04")
Expand All @@ -300,7 +300,7 @@ def read_interface(self):
self.assertEqual(interface.read_count, 1)
self.assertEqual(interface.bytes_read, 8)
interface.stop_raw_logging()
time.sleep(0.01) # Allow file to be transferred
time.sleep(0.01) # Allow file to be transferred
filename = self.mock_s3.files()[0]
self.assertIn("myinterface_stream_read.bin.gz", filename)
self.assertEqual(self.mock_s3.data(filename), b"\x01\x02\x03\x04\x01\x02\x03\x04")
Expand Down Expand Up @@ -416,7 +416,7 @@ def write_interface(self, data, extra=None):
)
thread.start()
thread.join()
self.assertGreater(time.time() - start_time, 1)
self.assertGreater(time.time() - start_time, 0.9)
self.assertEqual(interface.write_count, 10)
self.assertEqual(interface.bytes_written, 40)

Expand Down Expand Up @@ -454,7 +454,7 @@ def write_interface(self, data, extra=None):
self.assertEqual(interface.write_count, 1)
self.assertEqual(interface.bytes_written, 6)
interface.stop_raw_logging()
time.sleep(0.01) # Allow file to be transferred
time.sleep(0.01) # Allow file to be transferred
filename = self.mock_s3.files()[0]
self.assertIn("myinterface_stream_write.bin.gz", filename)
self.assertEqual(self.mock_s3.data(filename), b"\x01\x02\x03\x04\x05\x06")
Expand Down Expand Up @@ -505,7 +505,7 @@ def write_interface(self, data, extra=None):
self.assertEqual(interface.write_count, 1)
self.assertEqual(interface.bytes_written, 6)
interface.stop_raw_logging()
time.sleep(0.01) # Allow file to be transferred
time.sleep(0.01) # Allow file to be transferred
filename = self.mock_s3.files()[0]
self.assertIn("myinterface_stream_write.bin.gz", filename)
self.assertEqual(self.mock_s3.data(filename), b"\x01\x02\x03\x04\x08\x07")
Expand Down Expand Up @@ -763,7 +763,7 @@ def test_properly_handles_options_that_support_multiple_instances(self):
i.options["TEST_OPTION"] = [["value1", "value2"], ["value3", "value4"]]

i2 = Interface()
with patch.object(i2, 'set_option') as mock_set_option:
with patch.object(i2, "set_option") as mock_set_option:
i.copy_to(i2)
# Should be called twice with each sub-array
mock_set_option.assert_any_call("TEST_OPTION", ["value1", "value2"])
Expand Down
20 changes: 20 additions & 0 deletions openc3/spec/io/json_rpc_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@ module OpenC3
bytes = "\xf0\x28\x8c\x28" # Invalid 4 Octet Sequence
expect(bytes.as_json).to eql({"json_class" => "String", "raw" => bytes.unpack("C*")})
end

it "encodes binary data with high bytes as json_class format" do
# Test binary string with bytes > 127 (even if valid UTF-8)
# This simulates data from hex_to_byte_string like 0xDEAD
bytes = "\xDE\xAD\xBE\xEF".b
result = bytes.as_json
expect(result).to eql({"json_class" => "String", "raw" => [222, 173, 190, 239]})

# Verify round-trip encoding/decoding
json_str = JSON.generate(result, allow_nan: true)
decoded = JSON.parse(json_str, allow_nan: true, create_additions: true)
expect(decoded).to eq(bytes)
expect(decoded.encoding).to eq(Encoding::ASCII_8BIT)
end

it "does not encode plain ASCII strings even if ASCII-8BIT encoding" do
# Plain ASCII text should not be encoded as binary even if marked ASCII-8BIT
bytes = "NORMAL".force_encoding(Encoding::ASCII_8BIT)
expect(bytes.as_json).to eql("NORMAL")
end
end
end
end
Loading
Loading