openc3 7.2.0 → 7.2.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +11 -23
  3. data/data/config/interface_modifiers.yaml +15 -0
  4. data/data/config/target.yaml +17 -0
  5. data/data/config/widgets.yaml +28 -1
  6. data/lib/openc3/accessors/binary_accessor.rb +4 -0
  7. data/lib/openc3/api/cmd_api.rb +5 -1
  8. data/lib/openc3/api/limits_api.rb +72 -0
  9. data/lib/openc3/microservices/decom_common.rb +15 -4
  10. data/lib/openc3/microservices/decom_microservice.rb +6 -1
  11. data/lib/openc3/microservices/interface_microservice.rb +25 -11
  12. data/lib/openc3/microservices/queue_microservice.rb +4 -2
  13. data/lib/openc3/migrations/20260701000000_updated_at_to_int.rb +55 -0
  14. data/lib/openc3/models/cvt_model.rb +2 -2
  15. data/lib/openc3/models/db_sharded_model.rb +2 -2
  16. data/lib/openc3/models/interface_model.rb +16 -10
  17. data/lib/openc3/models/interface_status_model.rb +2 -2
  18. data/lib/openc3/models/metric_model.rb +2 -2
  19. data/lib/openc3/models/microservice_status_model.rb +2 -2
  20. data/lib/openc3/models/model.rb +2 -6
  21. data/lib/openc3/models/plugin_model.rb +15 -14
  22. data/lib/openc3/models/python_package_model.rb +3 -1
  23. data/lib/openc3/models/reingest_job_model.rb +1 -1
  24. data/lib/openc3/models/script_status_model.rb +73 -38
  25. data/lib/openc3/models/target_model.rb +13 -0
  26. data/lib/openc3/operators/operator.rb +15 -5
  27. data/lib/openc3/packets/commands.rb +3 -3
  28. data/lib/openc3/packets/limits.rb +23 -0
  29. data/lib/openc3/packets/packet.rb +5 -3
  30. data/lib/openc3/packets/packet_config.rb +5 -1
  31. data/lib/openc3/packets/structure.rb +14 -10
  32. data/lib/openc3/script/limits.rb +1 -1
  33. data/lib/openc3/script/suite_runner.rb +43 -9
  34. data/lib/openc3/script/web_socket_api.rb +1 -1
  35. data/lib/openc3/system/target.rb +4 -1
  36. data/lib/openc3/tools/table_manager/table_config.rb +4 -1
  37. data/lib/openc3/top_level.rb +1 -1
  38. data/lib/openc3/topics/limits_event_topic.rb +62 -3
  39. data/lib/openc3/topics/telemetry_decom_topic.rb +2 -2
  40. data/lib/openc3/utilities/cli_generator.rb +5 -1
  41. data/lib/openc3/utilities/env_helper.rb +13 -1
  42. data/lib/openc3/utilities/pypi_url.rb +39 -0
  43. data/lib/openc3/utilities/reingest_job.rb +1 -1
  44. data/lib/openc3/utilities/running_script.rb +17 -12
  45. data/lib/openc3/utilities/script.rb +14 -3
  46. data/lib/openc3/utilities/store_autoload.rb +39 -1
  47. data/lib/openc3/utilities/store_queued.rb +5 -1
  48. data/lib/openc3/version.rb +5 -5
  49. data/lib/openc3.rb +6 -1
  50. data/templates/tool_angular/package.json +2 -2
  51. data/templates/tool_react/package.json +1 -1
  52. data/templates/tool_svelte/package.json +1 -1
  53. data/templates/tool_vue/package.json +3 -3
  54. data/templates/widget/package.json +2 -2
  55. metadata +3 -1
@@ -134,7 +134,7 @@ module OpenC3
134
134
 
135
135
  # Update the Redis hash at primary_key and set the field "name"
136
136
  # to the JSON generated via calling as_json
137
- def create(update: false, force: false, queued: false, isoformat: false)
137
+ def create(update: false, force: false, queued: false)
138
138
  unless force
139
139
  existing = self.class.store.hget(@primary_key, @name)
140
140
  if existing
@@ -143,11 +143,7 @@ module OpenC3
143
143
  raise "#{@primary_key}:#{@name} doesn't exist at update" if update
144
144
  end
145
145
  end
146
- if isoformat
147
- @updated_at = Time.now.utc.iso8601
148
- else
149
- @updated_at = Time.now.utc.to_nsec_from_epoch
150
- end
146
+ @updated_at = Time.now.utc.to_nsec_from_epoch
151
147
 
152
148
  if queued
153
149
  write_store = self.class.store_queued
@@ -1,4 +1,4 @@
1
- # encoding: ascii-8bit
1
+ # encoding: utf-8
2
2
 
3
3
  # Copyright 2022 Ball Aerospace & Technologies Corp.
4
4
  # All Rights Reserved.
@@ -31,9 +31,11 @@ require 'openc3/models/tool_model'
31
31
  require 'openc3/models/widget_model'
32
32
  require 'openc3/models/microservice_model'
33
33
  require 'openc3/api/api'
34
+ require 'openc3/utilities/pypi_url'
34
35
  require 'tmpdir'
35
36
  require 'tempfile'
36
37
  require 'fileutils'
38
+ require 'open3'
37
39
 
38
40
  module OpenC3
39
41
  class EmptyGemFileError < StandardError; end
@@ -262,32 +264,31 @@ module OpenC3
262
264
  if pypi_url
263
265
  pypi_url += '/simple'
264
266
  end
265
- pypi_url ||= 'https://pypi.org/simple'
267
+ pypi_url ||= PypiUrl::DEFAULT
266
268
  end
267
269
  end
270
+ pypi_url = PypiUrl.validate(pypi_url)
268
271
  unless validate_only
272
+ # Build the pip arguments as an argv array and run pipinstall without a
273
+ # shell. pypi_url comes from a user-writable setting, so interpolating it
274
+ # into a shell command line allowed OS command injection. An argv array
275
+ # passes each argument verbatim with no shell metacharacter interpretation.
276
+ pip_args = ["-i", pypi_url]
277
+ pip_args += ["--trusted-host", URI.parse(pypi_url).host] unless ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
269
278
  if File.exist?(pyproject_path)
270
279
  Logger.info "Installing python packages from pyproject.toml with pypi_url=#{pypi_url}"
271
- if ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
272
- pip_args = "-i #{pypi_url} #{gem_path}"
273
- else
274
- pip_args = "-i #{pypi_url} --trusted-host #{URI.parse(pypi_url).host} #{gem_path}"
275
- end
280
+ pip_args << gem_path
276
281
  else
277
282
  Logger.info "Installing python packages from requirements.txt with pypi_url=#{pypi_url}"
278
- if ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
279
- pip_args = "-i #{pypi_url} -r #{requirements_path}"
280
- else
281
- pip_args = "-i #{pypi_url} --trusted-host #{URI.parse(pypi_url).host} -r #{requirements_path}"
282
- end
283
+ pip_args += ["-r", requirements_path]
283
284
  end
284
285
  # Capture output and check exit code so failures surface as a warning
285
286
  # rather than silently succeeding. pipinstall is non-fatal: the plugin
286
287
  # continues to install even if Python packages fail so that non-Python
287
288
  # functionality still works.
288
- output = `/openc3/bin/pipinstall #{pip_args}`
289
+ output, status = Open3.capture2e("/openc3/bin/pipinstall", *pip_args)
289
290
  puts output
290
- unless $?.success?
291
+ unless status.success?
291
292
  Logger.warn "Python package installation failed. Plugin Python microservices may not function correctly."
292
293
  end
293
294
  end
@@ -13,6 +13,7 @@
13
13
 
14
14
  require 'fileutils'
15
15
  require 'openc3/utilities/process_manager'
16
+ require 'openc3/utilities/pypi_url'
16
17
  require 'openc3/api/api'
17
18
  require 'pathname'
18
19
 
@@ -83,9 +84,10 @@ module OpenC3
83
84
  if pypi_url
84
85
  pypi_url += '/simple'
85
86
  end
86
- pypi_url ||= 'https://pypi.org/simple'
87
+ pypi_url ||= PypiUrl::DEFAULT
87
88
  end
88
89
  end
90
+ pypi_url = PypiUrl.validate(pypi_url)
89
91
  Logger.info "Installing python package: #{name_or_path}"
90
92
  if ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
91
93
  pip_args = ["-i", pypi_url, package_file_path]
@@ -1,4 +1,4 @@
1
- # encoding: ascii-8bit
1
+ # encoding: utf-8
2
2
 
3
3
  # Copyright 2026 OpenC3, Inc.
4
4
  # All Rights Reserved.
@@ -62,48 +62,83 @@ module OpenC3
62
62
  end
63
63
 
64
64
  def self.all(scope:, offset: 0, limit: 10, type: "running")
65
- if type == "running"
66
- keys = self.store.zrevrange("#{RUNNING_PRIMARY_KEY}__#{scope}__LIST", offset.to_i, offset.to_i + limit.to_i - 1)
67
- return [] if keys.empty?
68
- result = []
69
- result = self.store.redis_pool.pipelined do
70
- keys.each do |key|
71
- self.store.hget("#{RUNNING_PRIMARY_KEY}__#{scope}", key)
72
- end
73
- end
74
- result = result.map do |r|
75
- if r.nil?
76
- nil
77
- else
78
- JSON.parse(r, allow_nan: true, create_additions: true)
79
- end
80
- end
81
- return result
82
- else
83
- keys = self.store.zrevrange("#{COMPLETED_PRIMARY_KEY}__#{scope}__LIST", offset.to_i, offset.to_i + limit.to_i - 1)
84
- return [] if keys.empty?
85
- result = []
86
- result = self.store.redis_pool.pipelined do
87
- keys.each do |key|
88
- self.store.hget("#{COMPLETED_PRIMARY_KEY}__#{scope}", key)
89
- end
65
+ primary_key = (type == "running") ? RUNNING_PRIMARY_KEY : COMPLETED_PRIMARY_KEY
66
+ keys = self.store.zrevrange("#{primary_key}__#{scope}__LIST", offset.to_i, offset.to_i + limit.to_i - 1)
67
+ return [] if keys.empty?
68
+
69
+ result = self.store.redis_pool.pipelined do
70
+ keys.each do |key|
71
+ self.store.hget("#{primary_key}__#{scope}", key)
90
72
  end
91
- result = result.map do |r|
92
- if r.nil?
93
- nil
94
- else
95
- JSON.parse(r, allow_nan: true, create_additions: true)
96
- end
73
+ end
74
+ result = result.map do |r|
75
+ if r.nil?
76
+ nil
77
+ else
78
+ JSON.parse(r, allow_nan: true, create_additions: true)
97
79
  end
98
- return result
99
80
  end
81
+ return result
100
82
  end
101
83
 
102
84
  def self.count(scope:, type: "running")
103
- if type == "running"
104
- return self.store.zcount("#{RUNNING_PRIMARY_KEY}__#{scope}__LIST", 0, Float::INFINITY)
85
+ primary_key = (type == "running") ? RUNNING_PRIMARY_KEY : COMPLETED_PRIMARY_KEY
86
+ return self.store.zcount("#{primary_key}__#{scope}__LIST", 0, Float::INFINITY)
87
+ end
88
+
89
+ # Fields that a search term is matched against (case-insensitive substring)
90
+ SEARCH_FIELDS = ['name', 'filename', 'username', 'user_full_name', 'state']
91
+
92
+ # Return a single page of items along with the total count of matching items.
93
+ # When searching, this fetches, parses, and filters the full list only once
94
+ # (instead of doing that work separately for the page and the count).
95
+ # Returns [page_items, total]
96
+ def self.page(scope:, offset: 0, limit: 10, type: "running", search: nil)
97
+ primary_key = (type == "running") ? RUNNING_PRIMARY_KEY : COMPLETED_PRIMARY_KEY
98
+ if search and !search.to_s.empty?
99
+ items = self.filter_by_search(self.fetch_all(primary_key, scope), search)
100
+ return [items[offset.to_i, limit.to_i] || [], items.length]
105
101
  else
106
- return self.store.zcount("#{COMPLETED_PRIMARY_KEY}__#{scope}__LIST", 0, Float::INFINITY)
102
+ total = self.store.zcount("#{primary_key}__#{scope}__LIST", 0, Float::INFINITY)
103
+ keys = self.store.zrevrange("#{primary_key}__#{scope}__LIST", offset.to_i, offset.to_i + limit.to_i - 1)
104
+ page_items = keys.empty? ? [] : self.fetch(primary_key, scope, keys)
105
+ return [page_items, total]
106
+ end
107
+ end
108
+
109
+ # Fetch and parse the hash values for the given keys, preserving their order
110
+ def self.fetch(primary_key, scope, keys)
111
+ result = self.store.redis_pool.pipelined do
112
+ keys.each do |key|
113
+ self.store.hget("#{primary_key}__#{scope}", key)
114
+ end
115
+ end
116
+ result.map do |r|
117
+ # NOTE: create_additions is intentionally omitted here. ScriptStatusModel
118
+ # records are written via JSON.generate of as_json (plain hashes/arrays/
119
+ # primitives) and never contain a "json_class" marker, so create_additions
120
+ # has nothing to reconstruct. Enabling it disables the parser's fast path
121
+ # and makes this parse ~3x slower - costly since this runs over every
122
+ # record when listing/searching.
123
+ r.nil? ? nil : JSON.parse(r, allow_nan: true)
124
+ end
125
+ end
126
+
127
+ # Fetch and parse every item for the type, in list order (most recent first)
128
+ def self.fetch_all(primary_key, scope)
129
+ keys = self.store.zrevrange("#{primary_key}__#{scope}__LIST", 0, -1)
130
+ return [] if keys.empty?
131
+
132
+ self.fetch(primary_key, scope, keys).compact
133
+ end
134
+
135
+ # Select items where any SEARCH_FIELDS value contains the search term (case-insensitive)
136
+ def self.filter_by_search(items, search)
137
+ search = search.to_s.downcase
138
+ items.select do |item|
139
+ next false if item.nil?
140
+
141
+ SEARCH_FIELDS.any? { |field| item[field].to_s.downcase.include?(search) }
107
142
  end
108
143
  end
109
144
 
@@ -175,7 +210,7 @@ module OpenC3
175
210
 
176
211
  # Update the Redis hash at primary_key and set the field "name"
177
212
  # to the JSON generated via calling as_json
178
- def create(update: false, force: false, queued: false, isoformat: true)
213
+ def create(update: false, force: false, queued: false)
179
214
  @updated_at = Time.now.utc.to_nsec_from_epoch
180
215
 
181
216
  if queued
@@ -198,9 +233,9 @@ module OpenC3
198
233
 
199
234
  # Move to completed
200
235
  @primary_key = "#{COMPLETED_PRIMARY_KEY}__#{@scope}"
201
- create(update: false, force: force, queued: queued, isoformat: true)
236
+ create(update: false, force: force, queued: queued)
202
237
  else
203
- create(update: true, force: force, queued: queued, isoformat: true)
238
+ create(update: true, force: force, queued: queued)
204
239
  end
205
240
  end
206
241
 
@@ -63,6 +63,7 @@ module OpenC3
63
63
  attr_accessor :ignored_parameters
64
64
  attr_accessor :ignored_items
65
65
  attr_accessor :limits_groups
66
+ attr_accessor :stored_limits_mode
66
67
  attr_accessor :cmd_tlm_files
67
68
  attr_accessor :id
68
69
  attr_accessor :cmd_buffer_depth
@@ -384,6 +385,7 @@ module OpenC3
384
385
  ignored_parameters: [],
385
386
  ignored_items: [],
386
387
  limits_groups: [],
388
+ stored_limits_mode: 'PROCESS',
387
389
  cmd_tlm_files: [],
388
390
  id: nil,
389
391
  updated_at: nil,
@@ -412,6 +414,8 @@ module OpenC3
412
414
  @ignored_parameters = ignored_parameters
413
415
  @ignored_items = ignored_items
414
416
  @limits_groups = limits_groups
417
+ @stored_limits_mode = stored_limits_mode.to_s.upcase
418
+ @stored_limits_mode = 'PROCESS' unless %w(PROCESS LOG DISABLE).include?(@stored_limits_mode)
415
419
  @cmd_tlm_files = cmd_tlm_files
416
420
  @id = id
417
421
  @cmd_buffer_depth = cmd_buffer_depth
@@ -442,6 +446,7 @@ module OpenC3
442
446
  'ignored_parameters' => @ignored_parameters,
443
447
  'ignored_items' => @ignored_items,
444
448
  'limits_groups' => @limits_groups,
449
+ 'stored_limits_mode' => @stored_limits_mode,
445
450
  'cmd_tlm_files' => @cmd_tlm_files,
446
451
  'id' => @id,
447
452
  'updated_at' => @updated_at,
@@ -557,6 +562,14 @@ module OpenC3
557
562
  parser.verify_num_parameters(1, 1, "#{keyword} <Shard Number Starting from 0>")
558
563
  @db_shard = Integer(parameters[0])
559
564
 
565
+ when 'STORED_LIMITS_MODE'
566
+ parser.verify_num_parameters(1, 1, "#{keyword} <PROCESS, LOG, or DISABLE>")
567
+ mode = parameters[0].to_s.upcase
568
+ unless %w(PROCESS LOG DISABLE).include?(mode)
569
+ raise ConfigParser::Error.new(parser, "STORED_LIMITS_MODE must be one of PROCESS, LOG, or DISABLE")
570
+ end
571
+ @stored_limits_mode = mode
572
+
560
573
  else
561
574
  raise ConfigParser::Error.new(parser, "Unknown keyword and parameters for Target: #{keyword} #{parameters.join(" ")}")
562
575
  end
@@ -381,11 +381,21 @@ module OpenC3
381
381
  # Monitor processes and respawn if died
382
382
  Logger.info("#{self.class} Monitoring processes every #{@cycle_time} sec...")
383
383
  loop do
384
- update()
385
- remove_old()
386
- respawn_changed()
387
- start_new()
388
- respawn_dead()
384
+ # A single cycle must never be able to take down the operator process.
385
+ # update() in particular hits Redis every cycle and a transient network
386
+ # error (the kind that also makes targets reconnect) would otherwise
387
+ # unwind run() and exit the process, which in the container looks like a
388
+ # full operator restart. Catch, log, and keep cycling so the next cycle
389
+ # can recover once Redis is reachable again.
390
+ begin
391
+ update()
392
+ remove_old()
393
+ respawn_changed()
394
+ start_new()
395
+ respawn_dead()
396
+ rescue => e
397
+ Logger.error("#{self.class} cycle error, continuing: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
398
+ end
389
399
  break if @shutdown
390
400
 
391
401
  sleep(@cycle_time)
@@ -325,10 +325,10 @@ module OpenC3
325
325
  unless item.states.values.include?(value)
326
326
  if command.raw
327
327
  # Raw commands report missing value maps
328
- raise "Command parameter '#{command.target_name} #{command.packet_name} #{item_upcase}' = #{value.to_s.upcase} not one of #{item.states.values.join(', ')}"
328
+ raise RangeError, "Command parameter '#{command.target_name} #{command.packet_name} #{item_upcase}' = #{value.to_s.upcase} not one of #{item.states.values.join(', ')}"
329
329
  else
330
330
  # Normal commands report missing state maps
331
- raise "Command parameter '#{command.target_name} #{command.packet_name} #{item_upcase}' = #{value.to_s.upcase} not one of #{item.states.keys.join(', ')}"
331
+ raise RangeError, "Command parameter '#{command.target_name} #{command.packet_name} #{item_upcase}' = #{value.to_s.upcase} not one of #{item.states.keys.join(', ')}"
332
332
  end
333
333
  end
334
334
  end
@@ -340,7 +340,7 @@ module OpenC3
340
340
  # Perform Range Check on command parameter
341
341
  if not range.include?(range_check_value)
342
342
  range_check_value = "'#{range_check_value}'" if String === range_check_value
343
- raise "Command parameter '#{command.target_name} #{command.packet_name} #{item_upcase}' = #{range_check_value} not in valid range of #{range.first} to #{range.last}"
343
+ raise RangeError, "Command parameter '#{command.target_name} #{command.packet_name} #{item_upcase}' = #{range_check_value} not in valid range of #{range.first} to #{range.last}"
344
344
  end
345
345
  end
346
346
  end
@@ -153,6 +153,29 @@ module OpenC3
153
153
  return [limits_set, limits.persistence_setting, limits.enabled, limits_for_set[0], limits_for_set[1], limits_for_set[2], limits_for_set[3], limits_for_set[4], limits_for_set[5]]
154
154
  end
155
155
 
156
+ # Set the color for a telemetry item state
157
+ #
158
+ # @param target_name [String] Target Name
159
+ # @param packet_name [String] Packet Name
160
+ # @param item_name [String] Item Name
161
+ # @param state_name [String] State Name (e.g. 'CONNECTED')
162
+ # @param color [String or Symbol] New color: GREEN, YELLOW, or RED
163
+ # @return [Symbol] The color that was set
164
+ def set_state_color(target_name, packet_name, item_name, state_name, color)
165
+ packet = _get_packet(target_name, packet_name)
166
+ item = packet.get_item(item_name)
167
+ raise "Item #{target_name} #{packet_name} #{item_name} does not have any states" unless item.states
168
+ state_name = state_name.to_s.upcase
169
+ raise "State #{state_name} does not exist for #{target_name} #{packet_name} #{item_name}" unless item.states.key?(state_name)
170
+ color = color.to_s.upcase.intern
171
+ raise "Invalid state color #{color}. Must be one of #{PacketItem::STATE_COLORS.join(' ')}." unless PacketItem::STATE_COLORS.include?(color)
172
+ item.state_colors ||= {}
173
+ item.state_colors[state_name] = color
174
+ item.limits.enabled = true
175
+ packet.update_limits_items_cache(item)
176
+ return color
177
+ end
178
+
156
179
  protected
157
180
 
158
181
  def _get_packet(target_name, packet_name)
@@ -1285,7 +1285,7 @@ module OpenC3
1285
1285
  config
1286
1286
  end
1287
1287
 
1288
- def decom
1288
+ def decom(include_limits_states: true)
1289
1289
  # Read all the RAW at once because this could be optimized by the accessor
1290
1290
  json_hash = read_items(@sorted_items)
1291
1291
 
@@ -1301,7 +1301,9 @@ module OpenC3
1301
1301
  if item.format_string or item.units
1302
1302
  json_hash["#{item.name}__F"] = read_item(item, :FORMATTED, @buffer, given_raw)
1303
1303
  end
1304
- limits_state = item.limits.state
1304
+ if include_limits_states
1305
+ limits_state = item.limits.state
1306
+ end
1305
1307
  if limits_state
1306
1308
  json_hash["#{item.name}__L"] = limits_state
1307
1309
  end
@@ -1551,4 +1553,4 @@ module OpenC3
1551
1553
  item
1552
1554
  end
1553
1555
  end
1554
- end
1556
+ end
@@ -161,7 +161,11 @@ module OpenC3
161
161
  process_target_name = process_target_name.upcase
162
162
  parser = ConfigParser.new("https://docs.openc3.com/docs")
163
163
  parser.instance_variable_set(:@target_name, process_target_name)
164
- parser.parse_file(filename) do |keyword, params|
164
+ # run_erb is false: ERB is only rendered once, at plugin install time
165
+ # (TargetModel#deploy), before files are written to the targets bucket. The
166
+ # targets_modified overlay (e.g. dynamically created packets) is never ERB
167
+ # rendered, so it is treated as data here rather than executed as code.
168
+ parser.parse_file(filename, false, true, false) do |keyword, params|
165
169
  if @building_generic_conversion
166
170
  case keyword
167
171
  # Complete a generic conversion
@@ -604,19 +604,23 @@ module OpenC3
604
604
  if item.variable_bit_size
605
605
  # Bit size is determined by length field
606
606
  length_value = self.read(item.variable_bit_size['length_item_name'], :CONVERTED)
607
- if (item.data_type == :INT or item.data_type == :UINT) and not item.original_array_size
608
- case length_value
609
- when 0
610
- return 6
611
- when 1
612
- return 14
613
- when 2
614
- return 30
607
+ if length_value
608
+ if (item.data_type == :INT or item.data_type == :UINT) and not item.original_array_size
609
+ case length_value
610
+ when 0
611
+ return 6
612
+ when 1
613
+ return 14
614
+ when 2
615
+ return 30
616
+ else
617
+ return 62
618
+ end
615
619
  else
616
- return 62
620
+ return (length_value * item.variable_bit_size['length_bits_per_count']) + item.variable_bit_size['length_value_bit_offset']
617
621
  end
618
622
  else
619
- return (length_value * item.variable_bit_size['length_bits_per_count']) + item.variable_bit_size['length_value_bit_offset']
623
+ raise "Length value #{item.variable_bit_size['length_item_name']} for item #{item.name} is nil"
620
624
  end
621
625
  elsif item.original_bit_size <= 0
622
626
  # Bit size is full packet length - bits before item + negative bits saved at end
@@ -20,7 +20,7 @@ module OpenC3
20
20
  private
21
21
 
22
22
  # Define all the modification methods such that we can disconnect them
23
- %i(enable_limits disable_limits set_limits enable_limits_group disable_limits_group set_limits_set).each do |method_name|
23
+ %i(enable_limits disable_limits set_limits set_state_color enable_limits_group disable_limits_group set_limits_set delete_limits_set).each do |method_name|
24
24
  define_method(method_name) do |*args, **kw_args, &block|
25
25
  kw_args[:scope] = $openc3_scope unless kw_args[:scope]
26
26
  if $disconnect
@@ -26,6 +26,11 @@ module OpenC3
26
26
  end
27
27
 
28
28
  class SuiteRunner
29
+ # Default suite_runner options shared by all callers (e.g. openc3cli) so
30
+ # the values stay consistent in one place.
31
+ DEFAULT_METHOD = 'start'
32
+ DEFAULT_OPTIONS = ['continueAfterError'].freeze
33
+
29
34
  @@suites = []
30
35
  @@settings = {}
31
36
  @@suite_results = nil
@@ -42,17 +47,46 @@ module OpenC3
42
47
  @@suite_results
43
48
  end
44
49
 
50
+ # Validate the suite_runner option combinations that don't require the
51
+ # built suites. Shared by all callers (the running script via execute and
52
+ # openc3cli when constructing options) so the contract lives in one place.
53
+ # Suite/Group existence is checked in execute since it needs @@suites.
54
+ def self.validate_options(group: nil, script: nil)
55
+ raise ArgumentError, "Script #{script} requires a Group" if script and not group
56
+ end
57
+
58
+ # Build the canonical, validated suite_runner hash from raw values,
59
+ # applying defaults. Shared by openc3cli (constructing from CLI flags) and
60
+ # the running script (normalizing a received hash before dispatch) so the
61
+ # hash shape, defaults, and validation live in one place.
62
+ def self.build_options(suite:, group: nil, script: nil, method: nil, options: nil)
63
+ validate_options(group: group, script: script)
64
+ suite_runner = { 'suite' => suite }
65
+ if group
66
+ suite_runner['group'] = group
67
+ suite_runner['script'] = script if script
68
+ end
69
+ # A script always runs via start, matching the GUI
70
+ suite_runner['method'] = script ? DEFAULT_METHOD : (method || DEFAULT_METHOD)
71
+ suite_runner['options'] = options || DEFAULT_OPTIONS.dup
72
+ suite_runner
73
+ end
74
+
45
75
  def self.execute(result_string, suite_class, group_class = nil, script = nil)
46
76
  @@suite_results = SuiteResults.new
47
- @@suites.each do |suite|
48
- if suite.class == suite_class
49
- @@suite_results.start(result_string, suite_class, group_class, script, @@settings)
50
- loop do
51
- yield(suite)
52
- break if not @@settings['Loop'] or (ScriptStatus.instance.fail_count > 0 and @@settings['Break Loop On Error'])
53
- end
54
- break
55
- end
77
+ # Surface invalid suite / group / option combinations rather than
78
+ # silently running nothing or failing with an opaque nil error. An
79
+ # invalid script method raises later in Group#run_method.
80
+ validate_options(group: group_class, script: script)
81
+ suite = @@suites.find { |s| s.class == suite_class }
82
+ raise ArgumentError, "Suite #{suite_class} not found" unless suite
83
+ if group_class and not suite.scripts.key?(group_class)
84
+ raise ArgumentError, "Group #{group_class} not found in Suite #{suite_class}"
85
+ end
86
+ @@suite_results.start(result_string, suite_class, group_class, script, @@settings)
87
+ loop do
88
+ yield(suite)
89
+ break if not @@settings['Loop'] or (ScriptStatus.instance.fail_count > 0 and @@settings['Break Loop On Error'])
56
90
  end
57
91
  end
58
92
 
@@ -1,4 +1,4 @@
1
- # encoding: ascii-8bit
1
+ # encoding: utf-8
2
2
 
3
3
  # Copyright 2026 OpenC3, Inc.
4
4
  # All Rights Reserved.
@@ -102,7 +102,10 @@ module OpenC3
102
102
  def process_file(filename)
103
103
  Logger.instance.info "Processing target definition in file '#{filename}'"
104
104
  parser = ConfigParser.new("https://docs.openc3.com/docs/configuration/target")
105
- parser.parse_file(filename) do |keyword, parameters|
105
+ # run_erb is false: ERB is only rendered once, at plugin install time
106
+ # (TargetModel#deploy). At runtime target files are read already-rendered
107
+ # from the bucket, so they are treated as data, not re-executed as code.
108
+ parser.parse_file(filename, false, true, false) do |keyword, parameters|
106
109
  case keyword
107
110
  when 'LANGUAGE'
108
111
  usage = "#{keyword} <ruby | python>"
@@ -85,7 +85,10 @@ module OpenC3
85
85
  ConfigParser.new(
86
86
  'https://openc3.com/docs/tools/#table-manager-configuration-openc3--39',
87
87
  )
88
- parser.parse_file(filename) do |keyword, params|
88
+ # run_erb is false: ERB is only rendered once, at plugin install time
89
+ # (TargetModel#deploy). Table definitions are read already-rendered from the
90
+ # bucket here, so they are treated as data, not re-executed as code.
91
+ parser.parse_file(filename, false, true, false) do |keyword, params|
89
92
  if @building_generic_conversion
90
93
  case keyword
91
94
  # Complete a generic conversion
@@ -108,7 +108,7 @@ module OpenC3
108
108
  # This method is taken directly from the Rails source:
109
109
  # https://api.rubyonrails.org/v5.2/classes/ActiveStorage/Filename.html#method-i-sanitized
110
110
  # NOTE: I removed the '/' character because we have to allow this in order to traverse the path
111
- sanitized = path.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "").strip.tr("\u{202E}%$|:;\t\r\n\\", "-").gsub('..', '-')
111
+ sanitized = path.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\u{FFFD}").strip.tr("\u{202E}%$|:;\t\r\n\\", "-").gsub('..', '-')
112
112
  if sanitized != path
113
113
  raise StorageError, "Invalid path: #{path}"
114
114
  end