openc3 7.0.0 → 7.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a06901f01bc9d8e7b6f0b123296da67475c08ea9cf50ef788a86b6da1308fcb
4
- data.tar.gz: 34732b912cea1b4677f3e94ac0ba9f437e99217ccf54f9250816176ef5b63db1
3
+ metadata.gz: 36f12f777f56b7d64a19cfd89b8886352b6f0fb06bbda59d95d5643b8d4c71f6
4
+ data.tar.gz: 1ebffbb1399279e391a47dde42e6f8b9492be10b389c8e63d148aab402ee614c
5
5
  SHA512:
6
- metadata.gz: 5cda606ccb010606ad4b432685f53428cbf10b216f1cd06f2f55baac28fb723514dc3f8dfeadafc0958734237d3a7faf3f42439695481145914434e2a4bb1a58
7
- data.tar.gz: 98532ea424c421554d45a6d155fadfc27e67ddf969a96be7653daebb30d5530dbb7601e13b5d3a3afa7dfd56718c161c060e81d67a5ee967a9248225554e1a03
6
+ metadata.gz: ee971acf5bfd378f5a238263b467385286997900bea7fe549b8458c707568fb3a2265b261ee8794601bd42db7d0c2e8a983cf3d81a52c0c5069b9be0b41c4966
7
+ data.tar.gz: 5ba0ea7fb684a6e3e029864289612975db9555d1754f30f65baf4cf3193208516ea74029bb889b4575014b7953c45990c7e515dcf8f43d292bf8fc844de15a6b
data/bin/openc3cli CHANGED
@@ -75,6 +75,7 @@ def print_usage
75
75
  puts " cli pkguninstall PKGFILENAME SCOPE # Uninstall loaded package (Ruby gem or python package)"
76
76
  puts " cli xtce_converter # Convert to and from the XTCE format. Run with --help for more info."
77
77
  puts " cli cstol_converter # Converts CSTOL files (.prc) to COSMOS. Run with --help for more info."
78
+ puts " cli setpassword # Set the initial password from OPENC3_API_PASSWORD env var"
78
79
  puts ""
79
80
  end
80
81
 
@@ -588,35 +589,44 @@ def run_bridge(filename, params)
588
589
  end
589
590
  end
590
591
 
591
- def cli_script_monitor(script_id)
592
+ def cli_script_monitor(script_id, format: 'text')
592
593
  ret_code = ERROR_CODE
593
594
  require 'openc3/script'
595
+ require 'openc3/utilities/ctrf'
594
596
  begin
595
597
  OpenC3::RunningScriptWebSocketApi.new(id: script_id) do |api|
596
598
  while (resp = api.read) do
597
599
  # see ScriptRunner.vue for types and states
598
600
  case resp['type']
599
601
  when 'file'
600
- puts "Filename #{resp['filename']} scope #{resp['scope']}"
602
+ puts "Filename #{resp['filename']} scope #{resp['scope']}" unless format == 'ctrf'
601
603
  when 'line'
602
604
  fn = resp['filename'].nil? ? '<no file>' : resp['filename']
603
- puts "At [#{fn}:#{resp['line_no']}] state [#{resp['state']}]"
605
+ puts "At [#{fn}:#{resp['line_no']}] state [#{resp['state']}]" unless format == 'ctrf'
604
606
  if resp['state'] == 'error' or resp['state'] == 'crashed'
605
607
  $script_interrupt_text = ''
606
- puts 'script failed'
608
+ puts 'script failed' unless format == 'ctrf'
607
609
  break
608
610
  end
609
611
  when 'output'
610
- puts resp['line']
612
+ puts resp['line'] unless format == 'ctrf'
611
613
  when 'complete'
612
614
  $script_interrupt_text = ''
613
- puts 'script complete'
615
+ if resp['report']
616
+ if format == 'ctrf'
617
+ ctrf_data = OpenC3::Ctrf.convert_report(resp['report'])
618
+ puts JSON.pretty_generate(ctrf_data)
619
+ else
620
+ puts resp['report']
621
+ end
622
+ end
623
+ puts 'script complete' unless format == 'ctrf'
614
624
  ret_code = 0
615
625
  break
616
626
  # These conditions are all handled by the else
617
627
  # when 'running', 'breakpoint', 'waiting', 'time'
618
628
  else
619
- puts resp.pretty_inspect
629
+ puts resp.pretty_inspect unless format == 'ctrf'
620
630
  end
621
631
  end
622
632
  end
@@ -692,10 +702,10 @@ def cli_script_run(args, options)
692
702
  puts id
693
703
  $script_interrupt_text = " Script #{args[1]} still running remotely.\n" # for Ctrl-C
694
704
  if (options[:wait] < 1) then
695
- ret_code = cli_script_monitor(id)
705
+ ret_code = cli_script_monitor(id, format: options[:format])
696
706
  else
697
707
  Timeout::timeout(options[:wait], nil, "--wait #{options[:wait]} exceeded") do
698
- ret_code = cli_script_monitor(id)
708
+ ret_code = cli_script_monitor(id, format: options[:format])
699
709
  rescue Timeout::ExitException, Timeout::Error => e
700
710
  # Timeout exceptions are also raised by the Websocket API, so we check
701
711
  if e.message =~ /^--wait /
@@ -795,7 +805,7 @@ rescue => e
795
805
  end
796
806
 
797
807
  def cli_script(args=[])
798
- options = {scope: 'DEFAULT', disconnect: false, wait: 0, verbose: false}
808
+ options = {scope: 'DEFAULT', disconnect: false, wait: 0, verbose: false, format: 'text'}
799
809
  option_parser = OptionParser.new do |opts|
800
810
  opts.banner = "Usage: script --scope SCOPE [init | list | spawn | run]\n" +
801
811
  " init Initialize running scripts (Enterprise Only)\n" +
@@ -812,6 +822,9 @@ def cli_script(args=[])
812
822
  opts.on("--scope SCOPE", "Run with specified scope (default = DEFAULT)") do |arg|
813
823
  options[:scope] = arg
814
824
  end
825
+ opts.on("--format FORMAT", "Output format: text or ctrf (default = text)") do |arg|
826
+ options[:format] = arg
827
+ end
815
828
  opts.on("--suite SUITE", "Run with specified suite") do |arg|
816
829
  options[:suite] = arg
817
830
  end
@@ -892,6 +905,25 @@ def cli_script(args=[])
892
905
  exit(ret_code)
893
906
  end
894
907
 
908
+ def set_password
909
+ password = ENV['OPENC3_API_PASSWORD']
910
+ argon2_profile = ENV["OPENC3_ARGON2_PROFILE"]&.to_sym || :rfc_9106_low_memory
911
+ if password.nil? or password.empty?
912
+ abort "OPENC3_API_PASSWORD environment variable is required"
913
+ end
914
+ if password.length < 8
915
+ abort "Password must be at least 8 characters"
916
+ end
917
+ redis = Redis.new(url: $redis_url, username: ENV['OPENC3_REDIS_USERNAME'], password: ENV['OPENC3_REDIS_PASSWORD'])
918
+ if redis.exists('OPENC3__TOKEN') == 1
919
+ abort "Password has already been set. Use the web interface to change the password."
920
+ end
921
+ pw_hash = Argon2::Password.create(password, profile: argon2_profile)
922
+ redis.set('OPENC3__TOKEN', pw_hash)
923
+ puts "Password set successfully."
924
+ exit 0
925
+ end
926
+
895
927
  def migrate_password_hash
896
928
  password = ENV['OPENC3_API_PASSWORD']
897
929
  argon2_profile = ENV["OPENC3_ARGON2_PROFILE"]&.to_sym || :rfc_9106_low_memory
@@ -1377,6 +1409,22 @@ if not ARGV[0].nil? # argument(s) given
1377
1409
  end
1378
1410
  run_migrations(ARGV[1])
1379
1411
 
1412
+ when 'setpassword'
1413
+ if ARGV[1] == '--help' || ARGV[1] == '-h'
1414
+ puts "Usage: cli setpassword"
1415
+ puts ""
1416
+ puts "Set the initial COSMOS password from the OPENC3_API_PASSWORD environment variable."
1417
+ puts "This allows you to skip the password creation screen in the web interface."
1418
+ puts ""
1419
+ puts "The password must be at least 8 characters. This command will fail if a"
1420
+ puts "password has already been set."
1421
+ puts ""
1422
+ puts "Options:"
1423
+ puts " -h, --help Show this help message"
1424
+ exit 0
1425
+ end
1426
+ set_password()
1427
+
1380
1428
  when 'migratepassword'
1381
1429
  migrate_password_hash()
1382
1430
 
data/bin/pipinstall CHANGED
@@ -1,13 +1,45 @@
1
1
  #!/bin/sh
2
- uv venv "$PYTHONUSERBASE"
2
+ uv venv "$PYTHONUSERBASE" --allow-existing
3
3
  echo "uv pip install $@"
4
4
  uv pip install --python "$PYTHONUSERBASE" "$@"
5
5
  if [ $? -eq 0 ]; then
6
6
  echo "Command succeeded"
7
- else
8
- echo "Command failed - retrying with --no-index"
9
- uv pip install --python "$PYTHONUSERBASE" --no-index "$@"
10
- if [ $? -ne 0 ]; then
11
- echo "ERROR: uv pip install failed"
7
+ exit 0
8
+ fi
9
+
10
+ # Collect the last arg and all preceding args
11
+ LAST_ARG=""
12
+ OPTS=""
13
+ PREV=""
14
+ for ARG in "$@"; do
15
+ if [ -n "$PREV" ]; then
16
+ OPTS="${OPTS} ${PREV}"
17
+ fi
18
+ PREV="$ARG"
19
+ done
20
+ LAST_ARG="$PREV"
21
+
22
+ # If last arg is a directory with pyproject.toml, the build may have failed
23
+ # (e.g. Poetry package-mode = false). Try compiling and installing declared
24
+ # dependencies only, without attempting to build the package itself.
25
+ if [ -d "$LAST_ARG" ] && [ -f "$LAST_ARG/pyproject.toml" ]; then
26
+ echo "Warning: Failed to build Python package, attempting to install declared dependencies from pyproject.toml"
27
+ TMPFILE=$(mktemp)
28
+ uv pip compile ${OPTS} "${LAST_ARG}/pyproject.toml" > "$TMPFILE"
29
+ if [ $? -eq 0 ] && [ -s "$TMPFILE" ]; then
30
+ uv pip install --python "$PYTHONUSERBASE" ${OPTS} -r "$TMPFILE"
31
+ if [ $? -eq 0 ]; then
32
+ echo "Dependencies installed successfully"
33
+ rm -f "$TMPFILE"
34
+ exit 0
35
+ fi
12
36
  fi
37
+ rm -f "$TMPFILE"
38
+ fi
39
+
40
+ echo "Warning: Install failed - retrying with --no-index"
41
+ uv pip install --python "$PYTHONUSERBASE" --no-index "$@"
42
+ if [ $? -ne 0 ]; then
43
+ echo "ERROR: uv pip install failed"
44
+ exit 1
13
45
  fi
@@ -199,6 +199,7 @@ HIDDEN:
199
199
  summary: Hides this command from all OpenC3 tools such as Command Sender and Handbook Creator
200
200
  description: Hidden commands do not appear in the Script Runner popup helper when writing scripts.
201
201
  The command still exists in the system and can be sent by scripts.
202
+ since: 6.10.1
202
203
  DISABLED:
203
204
  summary: Disables this command from being sent
204
205
  description: Hides the command and also disables it from being sent by scripts.
@@ -192,4 +192,5 @@ HIDDEN:
192
192
  summary: Hides this item from all the OpenC3 tools
193
193
  description: This item will not appear in PacketViewer or Item Choosers.
194
194
  It also hides this item from appearing in the Script Runner popup helper
195
- when writing scripts. The item will also not be included in decom data.
195
+ when writing scripts. The item will also not be included in decom data.
196
+ since: 6.10.1
@@ -3,7 +3,9 @@ HIDDEN:
3
3
  summary: Indicates that the parameter should not be shown to the user in the Table Manager GUI
4
4
  description: Hidden parameters still exist and will be saved to the resulting
5
5
  binary. This is useful for padding and other essential but non-user editable fields.
6
+ since: 6.10.1
6
7
  UNEDITABLE:
7
8
  summary: Indicates that the parameter should be shown to the user but not editable.
8
- description: Uneditable parameters are useful for control fields which the user
9
+ description:
10
+ Uneditable parameters are useful for control fields which the user
9
11
  may be interested in but should not be able to edit.
@@ -53,6 +53,9 @@ module OpenC3
53
53
  return nil if item.data_type == :DERIVED
54
54
  configure()
55
55
 
56
+ # No template items to read (e.g. command with fixed template string)
57
+ return nil if @item_keys.empty?
58
+
56
59
  # Scan the response for all the variables in brackets <VARIABLE>
57
60
  values = buffer.scan(@read_regexp)[0]
58
61
  if !values || (values.length != @item_keys.length)
@@ -73,6 +76,12 @@ module OpenC3
73
76
  result = {}
74
77
  configure()
75
78
 
79
+ # No template items to read (e.g. command with fixed template string)
80
+ if @item_keys.empty?
81
+ items.each { |item| result[item.name] = nil }
82
+ return result
83
+ end
84
+
76
85
  # Scan the response for all the variables in brackets <VARIABLE>
77
86
  values = buffer.scan(@read_regexp)[0]
78
87
  if !values || (values.length != @item_keys.length)
@@ -295,17 +295,12 @@ module OpenC3
295
295
  else
296
296
  data, extra = protocol.read_data(data)
297
297
  end
298
- protocol.read_protocol_output_base(data, extra) unless blank_test
298
+ protocol.read_protocol_output_base(data, extra) unless blank_test or data == :STOP or data == :DISCONNECT
299
299
  if data == :DISCONNECT
300
300
  Logger.info("#{@name}: Protocol #{protocol.class} read_data requested disconnect")
301
301
  return nil
302
302
  end
303
303
  break if data == :STOP
304
- if blank_test
305
- # This means the blank test returned something so we can log
306
- protocol.read_protocol_input_base('', nil)
307
- protocol.read_protocol_output_base(data, extra)
308
- end
309
304
  end
310
305
  next if data == :STOP
311
306
 
@@ -116,7 +116,7 @@ module OpenC3
116
116
  microservice_cmd(topic, msg_id, msg_hash, redis)
117
117
  elsif topic =~ /__DECOMINTERFACE/
118
118
  if msg_hash.key?('inject_tlm')
119
- handle_inject_tlm(msg_hash['inject_tlm'])
119
+ handle_inject_tlm_with_ack(msg_hash['inject_tlm'], msg_id)
120
120
  next
121
121
  end
122
122
  if msg_hash.key?('build_cmd')
@@ -34,10 +34,26 @@ module OpenC3
34
34
  packet.received_time = Time.now.sys
35
35
  packet.received_count = TargetModel.increment_telemetry_count(packet.target_name, packet.packet_name, 1, scope: @scope)
36
36
  TelemetryTopic.write_packet(packet, scope: @scope)
37
- # If the inject_tlm parameters are bad we rescue so
38
- # interface_microservice and decom_microservice can continue
39
- rescue => e
40
- @logger.error "inject_tlm error due to #{e.message}"
37
+ end
38
+
39
+ def handle_inject_tlm_with_ack(inject_tlm_json, msg_id)
40
+ inject_tlm_hash = JSON.parse(inject_tlm_json, allow_nan: true, create_additions: true)
41
+ target_name = inject_tlm_hash['target_name']
42
+ ack_topic = "{#{@scope}__ACKCMD}TARGET__#{target_name}"
43
+ begin
44
+ handle_inject_tlm(inject_tlm_json)
45
+ msg_hash = {
46
+ id: msg_id,
47
+ result: 'SUCCESS'
48
+ }
49
+ rescue => error
50
+ @logger.error "inject_tlm error due to #{error.message}"
51
+ msg_hash = {
52
+ id: msg_id,
53
+ result: error.message
54
+ }
55
+ end
56
+ Topic.write_topic(ack_topic, msg_hash)
41
57
  end
42
58
 
43
59
  def handle_build_cmd(build_cmd_json, msg_id)
@@ -65,8 +81,7 @@ module OpenC3
65
81
  rescue => error
66
82
  msg_hash = {
67
83
  id: msg_id,
68
- result: 'ERROR',
69
- message: error.message
84
+ result: error.message
70
85
  }
71
86
  end
72
87
  Topic.write_topic(ack_topic, msg_hash)
@@ -97,8 +112,7 @@ module OpenC3
97
112
  rescue => error
98
113
  msg_hash = {
99
114
  id: msg_id,
100
- result: 'ERROR',
101
- message: error.message
115
+ result: error.message
102
116
  }
103
117
  end
104
118
  Topic.write_topic(ack_topic, msg_hash)
@@ -179,7 +179,12 @@ module OpenC3
179
179
  next 'SUCCESS'
180
180
  end
181
181
  if msg_hash.key?('inject_tlm')
182
- handle_inject_tlm(msg_hash['inject_tlm'])
182
+ begin
183
+ handle_inject_tlm(msg_hash['inject_tlm'])
184
+ rescue => e
185
+ @logger.error "#{@interface.name}: inject_tlm: #{e.formatted}"
186
+ next e.message
187
+ end
183
188
  next 'SUCCESS'
184
189
  end
185
190
  if msg_hash.key?('release_critical')
@@ -280,7 +280,15 @@ module OpenC3
280
280
  pip_args = "-i #{pypi_url} --trusted-host #{URI.parse(pypi_url).host} -r #{requirements_path}"
281
281
  end
282
282
  end
283
- puts `/openc3/bin/pipinstall #{pip_args}`
283
+ # Capture output and check exit code so failures surface as a warning
284
+ # rather than silently succeeding. pipinstall is non-fatal: the plugin
285
+ # continues to install even if Python packages fail so that non-Python
286
+ # functionality still works.
287
+ output = `/openc3/bin/pipinstall #{pip_args}`
288
+ puts output
289
+ unless $?.success?
290
+ Logger.warn "Python package installation failed. Plugin Python microservices may not function correctly."
291
+ end
284
292
  end
285
293
  needs_dependencies = true
286
294
  end
@@ -99,7 +99,7 @@ module OpenC3
99
99
  def self.destroy(name, scope:)
100
100
  package_name, version = self.extract_name_and_version(name)
101
101
  Logger.info "Uninstalling package: #{name}"
102
- pip_args = ["-y", package_name]
102
+ pip_args = [package_name]
103
103
  result = OpenC3::ProcessManager.instance.spawn(["/openc3/bin/pipuninstall"] + pip_args, "package_uninstall", name, Time.now + 3600.0, scope: scope)
104
104
  return result.name
105
105
  end
@@ -32,10 +32,12 @@ module OpenC3
32
32
  ACTION_TYPES = [SCRIPT_REACTION, COMMAND_REACTION, NOTIFY_REACTION]
33
33
 
34
34
  def self.create_unique_name(scope:)
35
- reaction_names = self.names(scope: scope) # comes back sorted
35
+ reaction_names = self.names(scope: scope)
36
36
  num = 1 # Users count with 1
37
- if reaction_names[-1]
38
- num = reaction_names[-1][5..-1].to_i + 1
37
+ unless reaction_names.empty?
38
+ # Extract numeric suffixes and find the max to avoid lexicographic sort issues
39
+ max_num = reaction_names.map { |name| name[5..-1].to_i }.max
40
+ num = max_num + 1
39
41
  end
40
42
  return "REACT#{num}"
41
43
  end
@@ -77,7 +79,7 @@ module OpenC3
77
79
  end
78
80
 
79
81
  attr_reader :name, :scope, :snooze, :triggers, :actions, :enabled, :trigger_level, :snoozed_until
80
- attr_accessor :username, :shard
82
+ attr_accessor :username, :shard, :label
81
83
 
82
84
  def initialize(
83
85
  name:,
@@ -90,6 +92,7 @@ module OpenC3
90
92
  snoozed_until: nil,
91
93
  username: nil,
92
94
  shard: 0,
95
+ label: nil,
93
96
  updated_at: nil
94
97
  )
95
98
  super("#{scope}#{PRIMARY_KEY}", name: name, scope: scope)
@@ -102,6 +105,7 @@ module OpenC3
102
105
  @triggers = validate_triggers(triggers)
103
106
  @username = username
104
107
  @shard = shard.to_i # to_i to handle nil
108
+ @label = label
105
109
  @updated_at = updated_at
106
110
  end
107
111
 
@@ -177,28 +181,40 @@ module OpenC3
177
181
  return actions
178
182
  end
179
183
 
180
- def verify_triggers
184
+ # Validate that all triggers exist, but do not persist dependent changes yet.
185
+ # Returns the list of trigger models that need updating.
186
+ def validate_triggers_exist
181
187
  if @triggers.empty?
182
188
  raise ReactionInputError.new "reaction must contain at least one valid trigger: #{@triggers}"
183
189
  end
184
190
 
191
+ models_to_update = []
185
192
  @triggers.each do | trigger |
186
193
  model = TriggerModel.get(name: trigger['name'], group: trigger['group'], scope: @scope)
187
194
  if model.nil?
188
195
  raise ReactionInputError.new "failed to find trigger: #{trigger}"
189
196
  end
190
- model.update_dependents(dependent: @name)
191
- model.update()
197
+ unless model.dependents.include?(@name)
198
+ model.update_dependents(dependent: @name)
199
+ models_to_update << model
200
+ end
192
201
  end
202
+ models_to_update
203
+ end
204
+
205
+ # Persist dependent changes to trigger models
206
+ def commit_trigger_dependents(models)
207
+ models.each { |model| model.update() }
193
208
  end
194
209
 
195
210
  def create
196
211
  unless Store.hget(@primary_key, @name).nil?
197
212
  raise ReactionInputError.new "existing reaction found: #{@name}"
198
213
  end
199
- verify_triggers()
214
+ models = validate_triggers_exist()
200
215
  @updated_at = Time.now.to_nsec_from_epoch
201
216
  Store.hset(@primary_key, @name, JSON.generate(as_json, allow_nan: true))
217
+ commit_trigger_dependents(models)
202
218
  notify(kind: 'created')
203
219
  end
204
220
 
@@ -222,9 +238,10 @@ module OpenC3
222
238
  end
223
239
  end
224
240
 
225
- verify_triggers()
241
+ models = validate_triggers_exist()
226
242
  @updated_at = Time.now.to_nsec_from_epoch
227
243
  Store.hset(@primary_key, @name, JSON.generate(as_json, allow_nan: true))
244
+ commit_trigger_dependents(models)
228
245
  # No notification as this is only called via reaction_controller which already notifies
229
246
  end
230
247
 
@@ -281,6 +298,7 @@ module OpenC3
281
298
  'actions' => @actions,
282
299
  'username' => @username,
283
300
  'shard' => @shard,
301
+ 'label' => @label,
284
302
  'updated_at' => @updated_at
285
303
  }
286
304
  end
@@ -52,10 +52,12 @@ module OpenC3
52
52
  TRIGGER_TYPE = 'trigger'.freeze
53
53
 
54
54
  def self.create_unique_name(group:, scope:)
55
- trigger_names = self.names(group: group, scope: scope) # comes back sorted
55
+ trigger_names = self.names(group: group, scope: scope)
56
56
  num = 1 # Users count with 1
57
- if trigger_names[-1]
58
- num = trigger_names[-1][4..-1].to_i + 1
57
+ unless trigger_names.empty?
58
+ # Extract numeric suffixes and find the max to avoid lexicographic sort issues
59
+ max_num = trigger_names.map { |name| name[4..-1].to_i }.max
60
+ num = max_num + 1
59
61
  end
60
62
  return "TRIG#{num}"
61
63
  end
@@ -97,6 +99,7 @@ module OpenC3
97
99
  end
98
100
 
99
101
  attr_reader :name, :scope, :state, :group, :enabled, :left, :operator, :right, :dependents, :roots
102
+ attr_accessor :label
100
103
 
101
104
  def initialize(
102
105
  name:,
@@ -108,6 +111,7 @@ module OpenC3
108
111
  state: false,
109
112
  enabled: true,
110
113
  dependents: nil,
114
+ label: nil,
111
115
  updated_at: nil
112
116
  )
113
117
  super("#{scope}#{PRIMARY_KEY}#{group}", name: name, scope: scope)
@@ -119,6 +123,7 @@ module OpenC3
119
123
  @operator = validate_operator(operator: operator)
120
124
  @right = validate_operand(operand: right, right: true)
121
125
  @dependents = dependents
126
+ @label = label
122
127
  @updated_at = updated_at
123
128
  selected_group = TriggerGroupModel.get(name: @group, scope: @scope)
124
129
  if selected_group.nil?
@@ -175,8 +180,11 @@ module OpenC3
175
180
  end
176
181
  end
177
182
 
178
- def verify_triggers
183
+ # Validate that all root triggers exist, but do not persist dependent changes yet.
184
+ # Returns the list of root trigger models that need updating.
185
+ def validate_roots
179
186
  @dependents = [] if @dependents.nil?
187
+ models_to_update = []
180
188
  @roots.each do | trigger |
181
189
  model = TriggerModel.get(name: trigger, group: @group, scope: @scope)
182
190
  if model.nil?
@@ -184,25 +192,33 @@ module OpenC3
184
192
  end
185
193
  unless model.dependents.include?(@name)
186
194
  model.update_dependents(dependent: @name)
187
- model.update()
195
+ models_to_update << model
188
196
  end
189
197
  end
198
+ models_to_update
199
+ end
200
+
201
+ # Persist dependent changes to root triggers
202
+ def commit_roots(models)
203
+ models.each { |model| model.update() }
190
204
  end
191
205
 
192
206
  def create
193
207
  unless Store.hget(@primary_key, @name).nil?
194
208
  raise TriggerInputError.new "existing trigger found: '#{@name}'"
195
209
  end
196
- verify_triggers()
210
+ models = validate_roots()
197
211
  @updated_at = Time.now.to_nsec_from_epoch
198
212
  Store.hset(@primary_key, @name, JSON.generate(as_json, allow_nan: true))
213
+ commit_roots(models)
199
214
  notify(kind: 'created')
200
215
  end
201
216
 
202
217
  def update
203
- verify_triggers()
218
+ models = validate_roots()
204
219
  @updated_at = Time.now.to_nsec_from_epoch
205
220
  Store.hset(@primary_key, @name, JSON.generate(as_json, allow_nan: true))
221
+ commit_roots(models)
206
222
  # No notification as this is only called via trigger_controller which already notifies
207
223
  end
208
224
 
@@ -267,6 +283,7 @@ module OpenC3
267
283
  'left' => @left,
268
284
  'operator' => @operator,
269
285
  'right' => @right,
286
+ 'label' => @label,
270
287
  'updated_at' => @updated_at,
271
288
  }
272
289
  end
@@ -288,6 +288,7 @@ module OpenC3
288
288
  start_time = Time.now.sys
289
289
  success, value = _openc3_script_wait_implementation_comparison(target_name, packet_name, item_name, type, comparison_to_eval, timeout, polling_rate, scope: scope, token: token, &block)
290
290
  value = "'#{value}'" if value.is_a? String # Show user the check against a quoted string
291
+ value = 'nil' if value.nil? # Show user nil value as 'nil'
291
292
  time_diff = Time.now.sys - start_time
292
293
  check_str = "CHECK: #{_upcase(target_name, packet_name, item_name)}"
293
294
  if comparison_to_eval
@@ -531,7 +532,7 @@ module OpenC3
531
532
  if comparison_to_eval
532
533
  _check_eval(target_name, packet_name, item_name, comparison_to_eval, value)
533
534
  else
534
- puts "CHECK: #{_upcase(target_name, packet_name, item_name)} == #{value}"
535
+ puts "CHECK: #{_upcase(target_name, packet_name, item_name)} == #{value.nil? ? 'nil' : value.inspect}"
535
536
  end
536
537
  end
537
538
 
@@ -632,6 +633,7 @@ module OpenC3
632
633
  start_time = Time.now.sys
633
634
  success, value = _openc3_script_wait_implementation_comparison(target_name, packet_name, item_name, value_type, comparison_to_eval, timeout, polling_rate, scope: scope, token: token)
634
635
  value = "'#{value}'" if value.is_a? String # Show user the check against a quoted string
636
+ value = 'nil' if value.nil? # Show user nil value as 'nil'
635
637
  time_diff = Time.now.sys - start_time
636
638
  wait_str = "WAIT: #{_upcase(target_name, packet_name, item_name)} #{comparison_to_eval}"
637
639
  value_str = "with value == #{value} after waiting #{time_diff} seconds"
@@ -863,8 +865,20 @@ module OpenC3
863
865
  # Show user the check against a quoted string
864
866
  # Note: We have to preserve the original 'value' variable because we're going to eval against it
865
867
  value_str = value.is_a?(String) ? "'#{value}'" : value
868
+ value_str = 'nil' if value.nil? # Show user nil value as 'nil'
866
869
  with_value = "with value == #{value_str}"
867
- if eval(string)
870
+
871
+ eval_is_valid = _check_eval_validity(value, comparison_to_eval)
872
+ unless eval_is_valid
873
+ message = "Invalid comparison for types"
874
+ if $disconnect
875
+ puts "ERROR: #{message}"
876
+ else
877
+ raise CheckError, message
878
+ end
879
+ end
880
+
881
+ if eval_is_valid && eval(string)
868
882
  puts "#{check_str} success #{with_value}"
869
883
  else
870
884
  message = "#{check_str} failed #{with_value}"
@@ -883,5 +897,28 @@ module OpenC3
883
897
  raise e
884
898
  end
885
899
  end
900
+
901
+ def _check_eval_validity(value, comparison)
902
+ return true if comparison.nil? || comparison.empty?
903
+
904
+ begin
905
+ operator, operand = extract_operator_and_operand_from_comparison(comparison)
906
+ rescue RuntimeError => e
907
+ if e.message.include?("Unable to parse operand")
908
+ # If we can't parse the operand, let the eval happen anyway
909
+ # It will raise an appropriate error (like NameError for undefined constants)
910
+ return true
911
+ end
912
+ raise # Re-raise invalid operator errors
913
+ rescue JSON::ParserError
914
+ return true
915
+ end
916
+
917
+ if [">=", "<=", ">", "<"].include?(operator)
918
+ return false if value.nil? || operand.nil? || value.is_a?(Array) || operand.is_a?(Array)
919
+ end
920
+
921
+ return true
922
+ end
886
923
  end
887
924
  end
@@ -61,20 +61,28 @@ module OpenC3
61
61
  # @param data [Hash, optional] Additional data to associate with the activity. Defaults to {}. Any activity can provide "username", "notes", and "customTitle". "command", "script", and "reserve" keys are reserves for the corresponding activity kind, with "environment" also available for script activities.
62
62
  # @param scope [String, optional] The scope of the activity. Defaults to OPENC3_SCOPE, must correspond to the timeline.
63
63
  def create_timeline_activity(name, kind:, start:, stop:, data: {}, scope: $openc3_scope)
64
- kind = kind.to_s.downcase()
65
- kinds = %w(command script reserve)
66
- unless kinds.include?(kind)
67
- raise "Unknown kind: #{kind}. Must be one of #{kinds.join(', ')}."
68
- end
69
- post_data = {}
70
- post_data['start'] = start.to_datetime.iso8601
71
- post_data['stop'] = stop.to_datetime.iso8601
72
- post_data['kind'] = kind
73
- post_data['data'] = data
64
+ post_data = _build_activity_data(kind, start, stop, data)
74
65
  response = $api_server.request('post', "/openc3-api/timeline/#{name}/activities", data: post_data, json: true, scope: scope)
75
66
  return _cal_handle_response(response, 'Failed to create timeline activity')
76
67
  end
77
68
 
69
+ # Updates an existing activity on the specified timeline.
70
+ #
71
+ # @param name [String] The name of the timeline.
72
+ # @param id [Integer] The start time / score of the activity (Unix seconds).
73
+ # @param kind [String] The kind of activity. Must be one of "COMMAND", "SCRIPT", or "RESERVE".
74
+ # @param start [DateTime] The new start time of the activity.
75
+ # @param stop [DateTime] The new stop time of the activity.
76
+ # @param data [Hash, optional] Additional data to associate with the activity. Defaults to {}. Any activity can provide "username", "notes", and "customTitle". "command", "script", and "reserve" keys are reserved for the corresponding activity kind, with "environment" also available for script activities.
77
+ # @param uuid [String] The UUID of the activity.
78
+ # @param scope [String, optional] The scope of the activity. Defaults to OPENC3_SCOPE.
79
+ def update_timeline_activity(name, id:, kind:, start:, stop:, uuid:, data: {}, scope: $openc3_scope)
80
+ post_data = _build_activity_data(kind, start, stop, data)
81
+ url = "/openc3-api/timeline/#{name}/activity/#{id}/#{uuid}"
82
+ response = $api_server.request('put', url, data: post_data, json: true, scope: scope)
83
+ return _cal_handle_response(response, 'Failed to update timeline activity')
84
+ end
85
+
78
86
  def get_timeline_activity(name, start, uuid, scope: $openc3_scope)
79
87
  response = $api_server.request('get', "/openc3-api/timeline/#{name}/activity/#{start}/#{uuid}", scope: scope)
80
88
  return _cal_handle_response(response, 'Failed to get timeline activity')
@@ -97,6 +105,20 @@ module OpenC3
97
105
  return _cal_handle_response(response, 'Failed to delete timeline activity')
98
106
  end
99
107
 
108
+ def _build_activity_data(kind, start, stop, data)
109
+ kind = kind.to_s.downcase()
110
+ kinds = %w(command script reserve)
111
+ unless kinds.include?(kind)
112
+ raise "Unknown kind: #{kind}. Must be one of #{kinds.join(', ')}."
113
+ end
114
+ {
115
+ 'start' => start.to_datetime.iso8601,
116
+ 'stop' => stop.to_datetime.iso8601,
117
+ 'kind' => kind,
118
+ 'data' => data,
119
+ }
120
+ end
121
+
100
122
  # Helper method to handle the response
101
123
  def _cal_handle_response(response, error_message)
102
124
  return nil if response.nil?
@@ -15,6 +15,7 @@
15
15
  # This file may also be used under the terms of a commercial license
16
16
  # if purchased from OpenC3, Inc.
17
17
 
18
+ require 'json'
18
19
  require 'openc3/utilities/store'
19
20
 
20
21
  module OpenC3
@@ -154,22 +155,54 @@ module OpenC3
154
155
  end
155
156
 
156
157
  def extract_fields_from_check_text(text)
157
- split_string = text.split
158
- raise "ERROR: Check improperly specified: #{text}" if split_string.length < 3
158
+ target_name, packet_name, item_name, comparison = text.split(nil, 4) # Ruby: second split arg is max number of resultant elements
159
+ raise "ERROR: Check improperly specified: #{text}" if item_name.nil?
159
160
 
160
- target_name = split_string[0]
161
- packet_name = split_string[1]
162
- item_name = split_string[2]
163
- comparison_to_eval = nil
164
- return [target_name, packet_name, item_name, comparison_to_eval] if split_string.length == 3
165
- raise "ERROR: Check improperly specified: #{text}" if split_string.length < 4
161
+ # comparison is either nil, the comparison string, or an empty string.
162
+ # We need it to not be an empty string.
163
+ comparison = nil if comparison&.length == 0
164
+
165
+ operator, _ = comparison&.split(nil, 2)
166
+ raise "ERROR: Use '==' instead of '=' in #{text}" if operator == "="
167
+
168
+ return [target_name, packet_name, item_name, comparison]
169
+ end
170
+
171
+ # Splits `check()` comparison expressions, e.g. "== 'foo bar'" becomes ["==", "foo bar"]
172
+ def extract_operator_and_operand_from_comparison(comparison)
173
+ valid_operators = ["==", "!=", ">=", "<=", ">", "<", "in"]
166
174
 
167
- split_string = text.split(/ /) # Split on regex spaces to preserve spaces in comparison
168
- index = split_string.rindex(item_name)
169
- comparison_to_eval = split_string[(index + 1)..(split_string.length - 1)].join(" ")
170
- raise "ERROR: Use '==' instead of '=': #{text}" if split_string[3] == '='
175
+ operator, operand = comparison.split(nil, 2) # Ruby: second split arg is max number of resultant elements
171
176
 
172
- return [target_name, packet_name, item_name, comparison_to_eval]
177
+ if operand.nil?
178
+ # Don't allow operator without operand
179
+ raise "ERROR: Invalid comparison, must specify an operand: #{comparison}" if !operator.nil?
180
+ return [nil, nil]
181
+ end
182
+
183
+ raise "ERROR: Invalid operator: '#{operator}'" unless valid_operators.include?(operator)
184
+
185
+ # Handle string operand: remove surrounding double/single quotes
186
+ if operand.match?(/^(['"])(.*)\1$/m) # Starts with single or double quote, and ends with matching quote
187
+ operand = operand[1..-2]
188
+ return [operator, operand]
189
+ end
190
+
191
+ # Handle other operand types
192
+ if operand == "nil"
193
+ operand = nil
194
+ elsif operand == "false"
195
+ operand = false
196
+ elsif operand == "true"
197
+ operand = true
198
+ else
199
+ begin
200
+ operand = JSON.parse(operand)
201
+ rescue JSON::ParserError
202
+ raise "ERROR: Unable to parse operand: #{operand}"
203
+ end
204
+ end
205
+ return [operator, operand]
173
206
  end
174
207
  end
175
208
  end
@@ -37,7 +37,7 @@ module OpenC3
37
37
  if msg_hash["result"] == "SUCCESS"
38
38
  return msg_hash
39
39
  else
40
- raise msg_hash["message"]
40
+ raise msg_hash["result"]
41
41
  end
42
42
  end
43
43
  end
@@ -45,14 +45,29 @@ module OpenC3
45
45
  raise "Timeout of #{timeout}s waiting for cmd ack. Does target '#{target_name}' exist?"
46
46
  end
47
47
 
48
- def self.inject_tlm(target_name, packet_name, item_hash = nil, type: :CONVERTED, scope:)
48
+ def self.inject_tlm(target_name, packet_name, item_hash = nil, type: :CONVERTED, timeout: 5, scope:)
49
49
  data = {}
50
50
  data['target_name'] = target_name.to_s.upcase
51
51
  data['packet_name'] = packet_name.to_s.upcase
52
52
  data['item_hash'] = item_hash
53
53
  data['type'] = type
54
- Topic.write_topic("#{scope}__DECOMINTERFACE__{#{target_name}}",
54
+ ack_topic = "{#{scope}__ACKCMD}TARGET__#{target_name}"
55
+ Topic.update_topic_offsets([ack_topic])
56
+ decom_id = Topic.write_topic("#{scope}__DECOMINTERFACE__{#{target_name}}",
55
57
  { 'inject_tlm' => JSON.generate(data, allow_nan: true) }, '*', 100)
58
+ time = Time.now
59
+ while (Time.now - time) < timeout
60
+ Topic.read_topics([ack_topic]) do |_topic, _msg_id, msg_hash, _redis|
61
+ if msg_hash["id"] == decom_id
62
+ if msg_hash["result"] == "SUCCESS"
63
+ return
64
+ else
65
+ raise msg_hash["result"]
66
+ end
67
+ end
68
+ end
69
+ end
70
+ raise "Timeout of #{timeout}s waiting for cmd ack. Does target '#{target_name}' exist?"
56
71
  end
57
72
 
58
73
  def self.get_tlm_buffer(target_name, packet_name, timeout: 5, scope:)
@@ -77,7 +92,7 @@ module OpenC3
77
92
  end
78
93
  return msg_hash
79
94
  else
80
- raise msg_hash["message"]
95
+ raise msg_hash["result"]
81
96
  end
82
97
  end
83
98
  end
@@ -111,13 +111,32 @@ module OpenC3
111
111
  Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'protocol_cmd' => JSON.generate(data, allow_nan: true) }, '*', 100)
112
112
  end
113
113
 
114
- def self.inject_tlm(interface_name, target_name, packet_name, item_hash = nil, type: :CONVERTED, scope:)
114
+ def self.inject_tlm(interface_name, target_name, packet_name, item_hash = nil, type: :CONVERTED, timeout: nil, scope:)
115
+ interface_name = interface_name.upcase
116
+
117
+ timeout = COMMAND_ACK_TIMEOUT_S unless timeout
118
+ ack_topic = "{#{scope}__ACKCMD}INTERFACE__#{interface_name}"
119
+ Topic.update_topic_offsets([ack_topic])
120
+
115
121
  data = {}
116
122
  data['target_name'] = target_name.to_s.upcase
117
123
  data['packet_name'] = packet_name.to_s.upcase
118
124
  data['item_hash'] = item_hash
119
125
  data['type'] = type
120
- Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'inject_tlm' => JSON.generate(data, allow_nan: true) }, '*', 100)
126
+ cmd_id = Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'inject_tlm' => JSON.generate(data, allow_nan: true) }, '*', 100)
127
+ time = Time.now
128
+ while (Time.now - time) < timeout
129
+ Topic.read_topics([ack_topic]) do |_topic, _msg_id, msg_hash, _redis|
130
+ if msg_hash["id"] == cmd_id
131
+ if msg_hash["result"] == "SUCCESS"
132
+ return
133
+ else
134
+ raise msg_hash["result"]
135
+ end
136
+ end
137
+ end
138
+ end
139
+ raise "Timeout of #{timeout}s waiting for cmd ack"
121
140
  end
122
141
 
123
142
  def self.interface_target_enable(interface_name, target_name, cmd_only: false, tlm_only: false, scope:)
@@ -0,0 +1,231 @@
1
+ # encoding: ascii-8bit
2
+
3
+ # Copyright 2026 OpenC3, Inc.
4
+ # All Rights Reserved.
5
+ #
6
+ # This program is distributed in the hope that it will be useful,
7
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
8
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
9
+ # See LICENSE.md for more details.
10
+ #
11
+ # This file may also be used under the terms of a commercial license
12
+ # if purchased from OpenC3, Inc.
13
+
14
+ require 'openc3/version'
15
+ require 'date'
16
+
17
+ module OpenC3
18
+ # Utility class for converting COSMOS script reports to CTRF (Common Test Report Format)
19
+ # See https://ctrf.io/docs/category/specification
20
+ class Ctrf
21
+ # Convert a COSMOS plain text script report to CTRF JSON format
22
+ # @param report_content [String] Plain text script report
23
+ # @param version [String] Version string to include in CTRF output (defaults to OpenC3::VERSION)
24
+ # @return [Hash] CTRF formatted report as a Ruby hash
25
+ def self.convert_report(report_content, version: OpenC3::VERSION)
26
+ lines = report_content.split("\n")
27
+ tests = []
28
+ summary = {}
29
+ settings = {}
30
+ in_settings = false
31
+ last_result = nil
32
+ in_summary = false
33
+
34
+ lines.each do |line|
35
+ next if line.nil?
36
+ line_clean = line.strip
37
+
38
+ if line_clean == 'Settings:'
39
+ in_settings = true
40
+ next
41
+ end
42
+
43
+ if in_settings
44
+ if line_clean.include?('Manual')
45
+ parts = line.split('=')
46
+ settings[:manual] = parts[1].strip if parts[1]
47
+ next
48
+ elsif line_clean.include?('Pause on Error')
49
+ parts = line.split('=')
50
+ settings[:pauseOnError] = parts[1].strip if parts[1]
51
+ next
52
+ elsif line_clean.include?('Continue After Error')
53
+ parts = line.split('=')
54
+ settings[:continueAfterError] = parts[1].strip if parts[1]
55
+ next
56
+ elsif line_clean.include?('Abort After Error')
57
+ parts = line.split('=')
58
+ settings[:abortAfterError] = parts[1].strip if parts[1]
59
+ next
60
+ elsif line_clean.include?('Loop =')
61
+ parts = line.split('=')
62
+ settings[:loop] = parts[1].strip if parts[1]
63
+ next
64
+ elsif line_clean.include?('Break Loop On Error')
65
+ parts = line.split('=')
66
+ settings[:breakLoopOnError] = parts[1].strip if parts[1]
67
+ in_settings = false
68
+ next
69
+ end
70
+ end
71
+
72
+ if line_clean == 'Results:'
73
+ last_result = line_clean
74
+ next
75
+ end
76
+
77
+ if last_result
78
+ # The first line should always have a timestamp and what it is executing
79
+ # Format: "2026-04-02T19:45:41.228209Z: Executing MySuite:ExampleGroup:script_2"
80
+ if last_result == 'Results:' and line_clean.include?("Executing")
81
+ # Split on first ': ' to separate timestamp from message
82
+ timestamp_and_msg = line_clean.split(': ', 2)
83
+ if timestamp_and_msg.length >= 2
84
+ timestamp = timestamp_and_msg[0]
85
+ begin
86
+ summary[:startTime] = DateTime.parse(timestamp).to_time.to_f * 1000
87
+ rescue Date::Error
88
+ # Skip malformed timestamps
89
+ end
90
+ end
91
+ last_result = line_clean
92
+ next
93
+ end
94
+
95
+ # Format: "2026-04-02T19:45:44.041472Z: ExampleGroup:script_2:PASS"
96
+ # Check if line contains a test result (but not Executing or Completed)
97
+ if !line_clean.include?("Executing") && !line_clean.include?("Completed")
98
+ # Try to parse as a test result line
99
+ timestamp_and_msg = line_clean.split(': ', 2)
100
+ if timestamp_and_msg.length >= 2
101
+ result_string = timestamp_and_msg[1]
102
+ # Must have at least 2 colons for group:name:status format
103
+ result_parts = result_string.split(':')
104
+ if result_parts.length >= 3
105
+ # Get start time from last_result - could be Executing line or previous test result
106
+ start_time = nil
107
+ if last_result
108
+ last_timestamp_and_msg = last_result.split(': ', 2)
109
+ if last_timestamp_and_msg.length >= 2
110
+ begin
111
+ start_time = DateTime.parse(last_timestamp_and_msg[0]).to_time.to_f * 1000
112
+ rescue Date::Error
113
+ # Skip malformed timestamps
114
+ end
115
+ end
116
+ end
117
+
118
+ # Parse current line timestamp
119
+ timestamp = timestamp_and_msg[0]
120
+ begin
121
+ end_time = DateTime.parse(timestamp).to_time.to_f * 1000
122
+ rescue Date::Error
123
+ last_result = line_clean
124
+ next # Skip lines with malformed timestamps
125
+ end
126
+
127
+ # Parse the test result: ExampleGroup:script_2:PASS
128
+ suite_group = result_parts[0]
129
+ name = result_parts[1]
130
+ status = result_parts[2]
131
+
132
+ format_status = case status
133
+ when 'PASS'
134
+ 'passed'
135
+ when 'SKIP'
136
+ 'skipped'
137
+ when 'FAIL'
138
+ 'failed'
139
+ else
140
+ 'unknown'
141
+ end
142
+
143
+ tests << {
144
+ name: "#{suite_group}:#{name}",
145
+ status: format_status,
146
+ duration: start_time ? (end_time - start_time) : 0,
147
+ }
148
+ last_result = line_clean
149
+ next
150
+ end
151
+ end
152
+ end
153
+
154
+ # Format: "2026-04-02T19:45:44.044982Z: Completed MySuite:ExampleGroup:script_2"
155
+ if line_clean.include?("Completed")
156
+ timestamp_and_msg = line_clean.split(': ', 2)
157
+ if timestamp_and_msg.length >= 2
158
+ timestamp = timestamp_and_msg[0]
159
+ begin
160
+ summary[:stopTime] = DateTime.parse(timestamp).to_time.to_f * 1000
161
+ rescue Date::Error
162
+ # Skip malformed timestamps
163
+ end
164
+ end
165
+ last_result = nil
166
+ next
167
+ end
168
+ end
169
+
170
+ if line_clean == '--- Test Summary ---'
171
+ in_summary = true
172
+ next
173
+ end
174
+
175
+ if in_summary
176
+ if line_clean.include?("Total Tests")
177
+ parts = line_clean.split(':')
178
+ summary[:total] = parts[1].to_i if parts[1]
179
+ end
180
+ if line_clean.include?("Pass:")
181
+ parts = line_clean.split(':')
182
+ summary[:passed] = parts[1].to_i if parts[1]
183
+ end
184
+ if line_clean.include?("Skip:")
185
+ parts = line_clean.split(':')
186
+ summary[:skipped] = parts[1].to_i if parts[1]
187
+ end
188
+ if line_clean.include?("Fail:")
189
+ parts = line_clean.split(':')
190
+ summary[:failed] = parts[1].to_i if parts[1]
191
+ end
192
+ end
193
+ end
194
+
195
+ # Build CTRF report
196
+ # See https://ctrf.io/docs/specification/root
197
+ return {
198
+ reportFormat: "CTRF",
199
+ results: {
200
+ # See https://ctrf.io/docs/specification/tool
201
+ tool: {
202
+ name: "COSMOS Script Runner",
203
+ version: version,
204
+ },
205
+ # See https://ctrf.io/docs/specification/summary
206
+ summary: {
207
+ tests: summary[:total],
208
+ passed: summary[:passed],
209
+ failed: summary[:failed],
210
+ pending: 0,
211
+ skipped: summary[:skipped],
212
+ other: 0,
213
+ start: summary[:startTime],
214
+ stop: summary[:stopTime],
215
+ },
216
+ # See https://ctrf.io/docs/specification/tests
217
+ tests: tests,
218
+ # See https://ctrf.io/docs/specification/extra
219
+ extra: {
220
+ manual: settings[:manual],
221
+ pauseOnError: settings[:pauseOnError],
222
+ continueAfterError: settings[:continueAfterError],
223
+ abortAfterError: settings[:abortAfterError],
224
+ loop: settings[:loop],
225
+ breakLoopOnError: settings[:breakLoopOnError],
226
+ },
227
+ },
228
+ }
229
+ end
230
+ end
231
+ end
@@ -127,14 +127,15 @@ module OpenC3
127
127
  # - Arrays are JSON-encoded: "[1, 2, 3]" or '["a", "b"]'
128
128
  # - Objects/Hashes are JSON-encoded: '{"key": "value"}'
129
129
  # - Binary data (BLOCK) is base64-encoded
130
- # - Large integers (64-bit) are stored as DECIMAL
130
+ # - Large integers (64-bit) are stored as VARCHAR strings
131
131
  #
132
132
  # @param value [Object] The value to decode
133
133
  # @param data_type [String] COSMOS data type (INT, UINT, FLOAT, STRING, BLOCK, DERIVED, etc.)
134
134
  # @param array_size [Integer, nil] If not nil, indicates this is an array item
135
135
  # @return [Object] The decoded value
136
136
  def self.decode_value(value, data_type: nil, array_size: nil)
137
- # Handle BigDecimal values from QuestDB DECIMAL columns (used for 64-bit integers)
137
+ # Handle BigDecimal values from legacy QuestDB DECIMAL columns
138
+ # (pre-existing tables may still use DECIMAL; new tables use VARCHAR)
138
139
  if value.is_a?(BigDecimal)
139
140
  return value.to_i if data_type == 'INT' || data_type == 'UINT'
140
141
  return value
@@ -167,7 +168,7 @@ module OpenC3
167
168
  end
168
169
  end
169
170
 
170
- # Integer values stored as strings (fallback path, normally DECIMAL)
171
+ # Integer values stored as VARCHAR strings (≥64-bit integers)
171
172
  if data_type == 'INT' || data_type == 'UINT'
172
173
  begin
173
174
  return Integer(value)
@@ -1,14 +1,14 @@
1
1
  # encoding: ascii-8bit
2
2
 
3
- OPENC3_VERSION = '7.0.0'
3
+ OPENC3_VERSION = '7.0.1'
4
4
  module OpenC3
5
5
  module Version
6
6
  MAJOR = '7'
7
7
  MINOR = '0'
8
- PATCH = '0'
8
+ PATCH = '1'
9
9
  OTHER = ''
10
- BUILD = '463726bb8dab631febe2e502ec0462a460bd326c'
10
+ BUILD = '665de79cef884b8d470b08817fd4a1fb538fb187'
11
11
  end
12
- VERSION = '7.0.0'
13
- GEM_VERSION = '7.0.0'
12
+ VERSION = '7.0.1'
13
+ GEM_VERSION = '7.0.1'
14
14
  end
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "<%= tool_name %>",
3
- "version": "7.0.0",
3
+ "version": "7.0.1",
4
4
  "scripts": {
5
5
  "ng": "ng",
6
6
  "start": "ng serve",
@@ -23,7 +23,7 @@
23
23
  "@angular/platform-browser-dynamic": "^18.2.6",
24
24
  "@angular/router": "^18.2.6",
25
25
  "@astrouxds/astro-web-components": "^7.24.0",
26
- "@openc3/js-common": "7.0.0",
26
+ "@openc3/js-common": "7.0.1",
27
27
  "rxjs": "~7.8.0",
28
28
  "single-spa": "^5.9.5",
29
29
  "single-spa-angular": "^9.2.0",
@@ -16,7 +16,7 @@
16
16
  "@emotion/react": "^11.13.3",
17
17
  "@emotion/styled": "^11.11.0",
18
18
  "@mui/material": "^6.1.1",
19
- "@openc3/js-common": "7.0.0",
19
+ "@openc3/js-common": "7.0.1",
20
20
  "react": "^18.2.0",
21
21
  "react-dom": "^18.2.0",
22
22
  "single-spa-react": "^5.1.4"
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "@astrouxds/astro-web-components": "^7.24.0",
15
- "@openc3/js-common": "7.0.0",
15
+ "@openc3/js-common": "7.0.1",
16
16
  "@smui/button": "^7.0.0",
17
17
  "@smui/common": "^7.0.0",
18
18
  "@smui/card": "^7.0.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "<%= tool_name %>",
3
- "version": "7.0.0",
3
+ "version": "7.0.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -11,8 +11,8 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@astrouxds/astro-web-components": "^7.24.0",
14
- "@openc3/js-common": "7.0.0",
15
- "@openc3/vue-common": "7.0.0",
14
+ "@openc3/js-common": "7.0.1",
15
+ "@openc3/vue-common": "7.0.1",
16
16
  "axios": "^1.7.7",
17
17
  "date-fns": "^4.1.0",
18
18
  "lodash": "^4.17.21",
@@ -22,7 +22,6 @@
22
22
  "devDependencies": {
23
23
  "@vitejs/plugin-vue": "^6.0.1",
24
24
  "@vue/eslint-config-prettier": "^9.0.0",
25
- "@vue/test-utils": "^2.4.6",
26
25
  "eslint": "^9.16.0",
27
26
  "eslint-config-prettier": "^9.1.0",
28
27
  "eslint-plugin-prettier": "^5.2.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "<%= widget_name %>",
3
- "version": "7.0.0",
3
+ "version": "7.0.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "@astrouxds/astro-web-components": "^7.24.0",
11
- "@openc3/vue-common": "7.0.0",
11
+ "@openc3/vue-common": "7.0.1",
12
12
  "vuetify": "^3.7.1"
13
13
  },
14
14
  "devDependencies": {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openc3
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.0.0
4
+ version: 7.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Melton
@@ -1245,6 +1245,7 @@ files:
1245
1245
  - lib/openc3/utilities/cosmos_rails_formatter.rb
1246
1246
  - lib/openc3/utilities/crc.rb
1247
1247
  - lib/openc3/utilities/csv.rb
1248
+ - lib/openc3/utilities/ctrf.rb
1248
1249
  - lib/openc3/utilities/env_helper.rb
1249
1250
  - lib/openc3/utilities/local_bucket.rb
1250
1251
  - lib/openc3/utilities/local_mode.rb