openc3 7.1.1 → 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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +11 -23
  3. data/data/config/command_modifiers.yaml +2 -2
  4. data/data/config/interface_modifiers.yaml +15 -0
  5. data/data/config/item_modifiers.yaml +10 -3
  6. data/data/config/target.yaml +17 -0
  7. data/data/config/widgets.yaml +28 -1
  8. data/lib/openc3/accessors/binary_accessor.rb +4 -0
  9. data/lib/openc3/api/cmd_api.rb +5 -1
  10. data/lib/openc3/api/limits_api.rb +72 -0
  11. data/lib/openc3/api/tlm_api.rb +6 -0
  12. data/lib/openc3/microservices/decom_common.rb +15 -4
  13. data/lib/openc3/microservices/decom_microservice.rb +6 -1
  14. data/lib/openc3/microservices/interface_microservice.rb +25 -11
  15. data/lib/openc3/microservices/microservice.rb +20 -5
  16. data/lib/openc3/microservices/queue_microservice.rb +4 -2
  17. data/lib/openc3/migrations/20260701000000_updated_at_to_int.rb +55 -0
  18. data/lib/openc3/models/cvt_model.rb +2 -2
  19. data/lib/openc3/models/db_sharded_model.rb +2 -2
  20. data/lib/openc3/models/interface_model.rb +16 -10
  21. data/lib/openc3/models/interface_status_model.rb +2 -2
  22. data/lib/openc3/models/metric_model.rb +2 -2
  23. data/lib/openc3/models/microservice_status_model.rb +2 -2
  24. data/lib/openc3/models/model.rb +2 -6
  25. data/lib/openc3/models/plugin_model.rb +15 -14
  26. data/lib/openc3/models/python_package_model.rb +3 -1
  27. data/lib/openc3/models/reingest_job_model.rb +1 -1
  28. data/lib/openc3/models/script_status_model.rb +73 -38
  29. data/lib/openc3/models/target_model.rb +13 -0
  30. data/lib/openc3/operators/operator.rb +49 -14
  31. data/lib/openc3/packets/commands.rb +3 -3
  32. data/lib/openc3/packets/limits.rb +23 -0
  33. data/lib/openc3/packets/packet.rb +5 -3
  34. data/lib/openc3/packets/packet_config.rb +22 -5
  35. data/lib/openc3/packets/structure.rb +14 -10
  36. data/lib/openc3/script/limits.rb +1 -1
  37. data/lib/openc3/script/suite.rb +1 -1
  38. data/lib/openc3/script/suite_runner.rb +43 -9
  39. data/lib/openc3/script/web_socket_api.rb +6 -2
  40. data/lib/openc3/system/target.rb +4 -1
  41. data/lib/openc3/tools/table_manager/table_config.rb +4 -1
  42. data/lib/openc3/top_level.rb +1 -1
  43. data/lib/openc3/topics/limits_event_topic.rb +62 -3
  44. data/lib/openc3/topics/telemetry_decom_topic.rb +2 -2
  45. data/lib/openc3/utilities/cli_generator.rb +432 -404
  46. data/lib/openc3/utilities/env_helper.rb +13 -1
  47. data/lib/openc3/utilities/pypi_url.rb +39 -0
  48. data/lib/openc3/utilities/questdb_client.rb +51 -4
  49. data/lib/openc3/utilities/reingest_job.rb +1 -1
  50. data/lib/openc3/utilities/running_script.rb +58 -15
  51. data/lib/openc3/utilities/script.rb +14 -3
  52. data/lib/openc3/utilities/simulated_target.rb +4 -2
  53. data/lib/openc3/utilities/store_autoload.rb +39 -1
  54. data/lib/openc3/utilities/store_queued.rb +5 -1
  55. data/lib/openc3/version.rb +5 -5
  56. data/lib/openc3.rb +6 -1
  57. data/templates/command_validator/command_validator.py +8 -10
  58. data/templates/command_validator/command_validator.rb +6 -9
  59. data/templates/plugin/LICENSE.md +16 -4
  60. data/templates/tool_angular/package.json +2 -2
  61. data/templates/tool_react/package.json +1 -1
  62. data/templates/tool_svelte/package.json +1 -1
  63. data/templates/tool_vue/package.json +3 -3
  64. data/templates/widget/package.json +2 -2
  65. metadata +3 -1
@@ -79,7 +79,7 @@ module OpenC3
79
79
  end
80
80
 
81
81
  # DB_Shard-aware create: writes to the store instance for the given db_shard.
82
- def _db_sharded_create(db_shard, update: false, force: false, queued: false, isoformat: false, expire_seconds: nil)
82
+ def _db_sharded_create(db_shard, update: false, force: false, queued: false, expire_seconds: nil)
83
83
  db_shard_store = self.class.store.instance(db_shard: db_shard)
84
84
  unless force
85
85
  existing = db_shard_store.hget(@primary_key, @name)
@@ -89,7 +89,7 @@ module OpenC3
89
89
  raise RuntimeError.new("#{@primary_key}:#{@name} doesn't exist at update") if update
90
90
  end
91
91
  end
92
- @updated_at = isoformat ? Time.now.utc.iso8601 : Time.now.utc.to_nsec_from_epoch
92
+ @updated_at = Time.now.utc.to_nsec_from_epoch
93
93
 
94
94
  if queued
95
95
  store = self.class.store_queued.instance(db_shard: db_shard)
@@ -444,9 +444,11 @@ module OpenC3
444
444
  microservice
445
445
  end
446
446
 
447
- # Looks up the deployed MicroserviceModel and destroy the microservice model
448
- # should should trigger the operator to kill the microservice that in turn
449
- # will destroy the InterfaceStatusModel when a stop is called.
447
+ # Destroys the deployed MicroserviceModel which triggers the operator to kill
448
+ # the microservice. Also directly destroys the Interface/RouterStatusModel in
449
+ # an ensure block. The microservice stop() no longer recreates the status
450
+ # model once cancel_thread is set, so undeploy must clean it up itself to
451
+ # avoid leaving an orphaned status model behind.
450
452
  def undeploy
451
453
  type = self.class._get_type
452
454
  name = "#{@scope}__#{type}__#{@name}"
@@ -455,15 +457,19 @@ module OpenC3
455
457
  model.destroy
456
458
  ConfigTopic.write({ kind: 'deleted', type: type.downcase, name: @name, plugin: @plugin }, scope: @scope)
457
459
  end
458
-
459
- if type == 'INTERFACE'
460
- status_model = InterfaceStatusModel.get_model(name: @name, scope: @scope)
461
- else
462
- status_model = RouterStatusModel.get_model(name: @name, scope: @scope)
463
- end
464
- status_model.destroy if status_model
465
460
  rescue Exception => error
466
461
  Logger.error("Error undeploying interface/router model #{@name} in scope #{@scope} due to #{error}")
462
+ ensure
463
+ begin
464
+ if type == 'INTERFACE'
465
+ status_model = InterfaceStatusModel.get_model(name: @name, scope: @scope)
466
+ else
467
+ status_model = RouterStatusModel.get_model(name: @name, scope: @scope)
468
+ end
469
+ status_model.destroy if status_model
470
+ rescue Exception => error
471
+ Logger.error("Error destroying #{type&.downcase || 'unknown'} status model #{@name} in scope #{@scope} due to #{error}")
472
+ end
467
473
  end
468
474
 
469
475
  def unmap_target(target_name, cmd_only: false, tlm_only: false)
@@ -118,8 +118,8 @@ module OpenC3
118
118
  @rxcnt = rxcnt
119
119
  end
120
120
 
121
- def create(update: false, force: false, queued: false, isoformat: false)
122
- _db_sharded_create(self.class._db_shard_for_name(@name, scope: @scope, use_cache: true), update: update, force: force, queued: queued, isoformat: isoformat)
121
+ def create(update: false, force: false, queued: false)
122
+ _db_sharded_create(self.class._db_shard_for_name(@name, scope: @scope, use_cache: true), update: update, force: force, queued: queued)
123
123
  end
124
124
 
125
125
  def destroy
@@ -77,8 +77,8 @@ module OpenC3
77
77
  @db_shard = db_shard.to_i
78
78
  end
79
79
 
80
- def create(update: false, force: false, queued: false, isoformat: false, expire_seconds: nil)
81
- _db_sharded_create(@db_shard, update: update, force: force, queued: queued, isoformat: isoformat, expire_seconds: expire_seconds)
80
+ def create(update: false, force: false, queued: false, expire_seconds: nil)
81
+ _db_sharded_create(@db_shard, update: update, force: force, queued: queued, expire_seconds: expire_seconds)
82
82
  end
83
83
 
84
84
  def destroy
@@ -76,8 +76,8 @@ module OpenC3
76
76
  @custom = custom
77
77
  end
78
78
 
79
- def create(update: false, force: false, queued: false, isoformat: false)
80
- _db_sharded_create(self.class._db_shard_for_name(@name, scope: @scope, use_cache: true), update: update, force: force, queued: queued, isoformat: isoformat)
79
+ def create(update: false, force: false, queued: false)
80
+ _db_sharded_create(self.class._db_shard_for_name(@name, scope: @scope, use_cache: true), update: update, force: force, queued: queued)
81
81
  end
82
82
 
83
83
  def destroy
@@ -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
@@ -238,6 +238,11 @@ module OpenC3
238
238
 
239
239
  OperatorProcess.setup()
240
240
  @cycle_time = (ENV['OPERATOR_CYCLE_TIME'] and ENV['OPERATOR_CYCLE_TIME'].to_f) || CYCLE_TIME # time in seconds
241
+ # Maximum number of new microservices to start per cycle. This spreads a
242
+ # large startup burst (e.g. installing a plugin with many targets) across
243
+ # multiple cycles so the shared services (object store, redis) aren't
244
+ # stampeded by every microservice connecting at once. 0 = no limit.
245
+ @max_start_per_cycle = (ENV['OPENC3_OPERATOR_MAX_START_PER_CYCLE'] || 5).to_i
241
246
 
242
247
  @ruby_process_name = ENV['OPENC3_RUBY']
243
248
  if RUBY_ENGINE != 'ruby'
@@ -262,10 +267,17 @@ module OpenC3
262
267
  def start_new
263
268
  @mutex.synchronize do
264
269
  if @new_processes.length > 0
265
- # Start all the processes
266
- Logger.info("#{self.class} starting each new process...")
267
- @new_processes.each { |_name, p| p.start }
268
- @new_processes = {}
270
+ # Start at most @max_start_per_cycle processes this cycle; any
271
+ # remaining stay queued in @new_processes and start on later cycles.
272
+ # This avoids a startup stampede when many microservices appear at
273
+ # once (e.g. a plugin install) overwhelming the object store / redis.
274
+ start_names = @new_processes.keys
275
+ start_names = start_names[0...@max_start_per_cycle] if @max_start_per_cycle > 0
276
+ Logger.info("#{self.class} starting #{start_names.length} of #{@new_processes.length} new process(es)...")
277
+ start_names.each do |name|
278
+ @new_processes[name].start
279
+ @new_processes.delete(name)
280
+ end
269
281
  end
270
282
  end
271
283
  end
@@ -273,12 +285,22 @@ module OpenC3
273
285
  def respawn_changed
274
286
  @mutex.synchronize do
275
287
  if @changed_processes.length > 0
276
- Logger.info("Cycling #{@changed_processes.length} changed microservices...")
277
- shutdown_processes(@changed_processes)
288
+ # Cycle at most @max_start_per_cycle changed microservices this cycle;
289
+ # any remaining stay queued in @changed_processes and are cycled on
290
+ # later cycles. This avoids a restart stampede when many microservices
291
+ # change at once (e.g. a configmap change) overwhelming shared
292
+ # services. Processes not yet cycled keep running until their turn.
293
+ cycle_names = @changed_processes.keys
294
+ cycle_names = cycle_names[0...@max_start_per_cycle] if @max_start_per_cycle > 0
295
+ cycle = @changed_processes.slice(*cycle_names)
296
+ Logger.info("Cycling #{cycle.length} of #{@changed_processes.length} changed microservices...")
297
+ shutdown_processes(cycle)
278
298
  break if @shutdown
279
299
 
280
- @changed_processes.each { |_name, p| p.start }
281
- @changed_processes = {}
300
+ cycle_names.each do |name|
301
+ @changed_processes[name].start
302
+ @changed_processes.delete(name)
303
+ end
282
304
  end
283
305
  end
284
306
  end
@@ -295,8 +317,11 @@ module OpenC3
295
317
 
296
318
  def respawn_dead
297
319
  @mutex.synchronize do
298
- @processes.each do |_name, p|
320
+ @processes.each do |name, p|
299
321
  break if @shutdown
322
+ # Skip processes still queued by the per-cycle start limit; they
323
+ # haven't been started yet so they aren't "dead" to be respawned.
324
+ next if @new_processes[name]
300
325
  p.output_increment
301
326
  unless p.alive?
302
327
  # Respawn process
@@ -356,11 +381,21 @@ module OpenC3
356
381
  # Monitor processes and respawn if died
357
382
  Logger.info("#{self.class} Monitoring processes every #{@cycle_time} sec...")
358
383
  loop do
359
- update()
360
- remove_old()
361
- respawn_changed()
362
- start_new()
363
- 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
364
399
  break if @shutdown
365
400
 
366
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
@@ -437,12 +441,25 @@ module OpenC3
437
441
  if packet.id_items.length > 0
438
442
  key = []
439
443
  id_signature = ""
440
- # Accessor class is part of the signature so packets in the same target
441
- # with different accessors trigger unique_id_mode (different accessors
442
- # decode the buffer differently, so the hash-lookup path is unsafe).
444
+ # Accessor class AND args are part of the signature so packets in the
445
+ # same target with different accessors -- or the same accessor class
446
+ # configured with different args -- trigger unique_id_mode. Different
447
+ # accessors (or same accessor, different args) decode the buffer
448
+ # differently, so the shared hash-lookup path is unsafe.
443
449
  packet.id_items.each do |item|
444
450
  key << item.id_value
445
- id_signature << "__#{item.key}__#{item.bit_offset}__#{item.bit_size}__#{item.data_type}__#{packet.accessor.class.to_s}"
451
+ id_signature << "__#{item.key}__#{item.bit_offset}__#{item.bit_size}__#{item.data_type}__#{packet.accessor.class.to_s}__#{packet.accessor.args.inspect}"
452
+ # STRUCTURE-derived id_items are decoded by the parent's structure
453
+ # accessor (see Accessor#read_item), so include that accessor's class
454
+ # and args in the signature too.
455
+ if item.parent_item
456
+ parent = packet.get_item(item.parent_item)
457
+ structure = parent.structure
458
+ if structure
459
+ structure_accessor = structure.accessor
460
+ id_signature << "__#{structure_accessor.class.to_s}__#{structure_accessor.args.inspect}"
461
+ end
462
+ end
446
463
  end
447
464
  target_id_value_hash[key] = packet
448
465
  target_id_signature = id_signature_hash[packet.target_name]
@@ -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