openc3 7.2.0 → 7.3.0

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +108 -29
  3. data/bin/pipinstall +5 -4
  4. data/bin/pipuninstall +5 -1
  5. data/bin/uvinstall +149 -0
  6. data/data/config/interface_modifiers.yaml +15 -0
  7. data/data/config/item_modifiers.yaml +2 -1
  8. data/data/config/microservice.yaml +5 -1
  9. data/data/config/screen.yaml +2 -1
  10. data/data/config/target.yaml +38 -0
  11. data/data/config/tool.yaml +3 -1
  12. data/data/config/widgets.yaml +43 -3
  13. data/data/xtce_schemas/SpaceSystem_20180204.xsd +5918 -0
  14. data/data/xtce_schemas/xml.xsd +116 -0
  15. data/ext/openc3/ext/telemetry/telemetry.c +1 -1
  16. data/lib/openc3/accessors/binary_accessor.rb +4 -0
  17. data/lib/openc3/accessors/template_accessor.rb +2 -2
  18. data/lib/openc3/api/cmd_api.rb +5 -1
  19. data/lib/openc3/api/interface_api.rb +2 -2
  20. data/lib/openc3/api/limits_api.rb +89 -3
  21. data/lib/openc3/api/router_api.rb +3 -3
  22. data/lib/openc3/api/tlm_api.rb +1 -1
  23. data/lib/openc3/interfaces/http_client_interface.rb +1 -1
  24. data/lib/openc3/io/json_drb.rb +24 -8
  25. data/lib/openc3/io/json_rpc.rb +0 -2
  26. data/lib/openc3/logs/log_writer.rb +43 -7
  27. data/lib/openc3/logs/packet_log_writer.rb +1 -2
  28. data/lib/openc3/microservices/decom_common.rb +15 -4
  29. data/lib/openc3/microservices/decom_microservice.rb +6 -1
  30. data/lib/openc3/microservices/interface_microservice.rb +26 -12
  31. data/lib/openc3/microservices/queue_microservice.rb +4 -2
  32. data/lib/openc3/migrations/20260701000000_updated_at_to_int.rb +55 -0
  33. data/lib/openc3/migrations/20260731000000_system_health_json.rb +41 -0
  34. data/lib/openc3/models/cvt_model.rb +7 -7
  35. data/lib/openc3/models/db_sharded_model.rb +2 -2
  36. data/lib/openc3/models/gem_model.rb +1 -1
  37. data/lib/openc3/models/interface_model.rb +17 -11
  38. data/lib/openc3/models/interface_status_model.rb +2 -2
  39. data/lib/openc3/models/metric_model.rb +2 -2
  40. data/lib/openc3/models/microservice_model.rb +21 -0
  41. data/lib/openc3/models/microservice_status_model.rb +2 -2
  42. data/lib/openc3/models/model.rb +2 -6
  43. data/lib/openc3/models/plugin_model.rb +204 -49
  44. data/lib/openc3/models/python_package_model.rb +210 -12
  45. data/lib/openc3/models/reingest_job_model.rb +1 -1
  46. data/lib/openc3/models/scope_model.rb +34 -30
  47. data/lib/openc3/models/script_status_model.rb +73 -38
  48. data/lib/openc3/models/target_model.rb +193 -6
  49. data/lib/openc3/models/tool_model.rb +33 -20
  50. data/lib/openc3/operators/microservice_operator.rb +33 -4
  51. data/lib/openc3/operators/operator.rb +15 -5
  52. data/lib/openc3/packets/commands.rb +7 -7
  53. data/lib/openc3/packets/limits.rb +33 -2
  54. data/lib/openc3/packets/packet.rb +6 -4
  55. data/lib/openc3/packets/packet_config.rb +5 -1
  56. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  57. data/lib/openc3/packets/parsers/xtce_converter.rb +183 -103
  58. data/lib/openc3/packets/parsers/xtce_parser.rb +71 -23
  59. data/lib/openc3/packets/structure.rb +16 -12
  60. data/lib/openc3/packets/telemetry.rb +3 -3
  61. data/lib/openc3/script/commands.rb +1 -1
  62. data/lib/openc3/script/extract.rb +20 -25
  63. data/lib/openc3/script/limits.rb +1 -1
  64. data/lib/openc3/script/suite_runner.rb +43 -9
  65. data/lib/openc3/script/web_socket_api.rb +45 -5
  66. data/lib/openc3/system/target.rb +4 -1
  67. data/lib/openc3/tools/table_manager/table_config.rb +4 -1
  68. data/lib/openc3/top_level.rb +1 -1
  69. data/lib/openc3/topics/command_decom_topic.rb +16 -1
  70. data/lib/openc3/topics/limits_event_topic.rb +63 -4
  71. data/lib/openc3/topics/telemetry_decom_topic.rb +2 -2
  72. data/lib/openc3/utilities/aws_bucket.rb +10 -2
  73. data/lib/openc3/utilities/cli_generator.rb +5 -1
  74. data/lib/openc3/utilities/csv.rb +5 -5
  75. data/lib/openc3/utilities/env_helper.rb +13 -1
  76. data/lib/openc3/utilities/local_mode.rb +4 -1
  77. data/lib/openc3/utilities/pypi_url.rb +39 -0
  78. data/lib/openc3/utilities/reingest_job.rb +1 -1
  79. data/lib/openc3/utilities/ruby_lex_utils.rb +5 -1
  80. data/lib/openc3/utilities/running_script.rb +160 -100
  81. data/lib/openc3/utilities/script.rb +60 -13
  82. data/lib/openc3/utilities/store_autoload.rb +39 -1
  83. data/lib/openc3/utilities/store_queued.rb +5 -1
  84. data/lib/openc3/version.rb +5 -5
  85. data/lib/openc3.rb +6 -1
  86. data/templates/plugin/plugin.gemspec +1 -1
  87. data/templates/tool_angular/package.json +2 -2
  88. data/templates/tool_react/package.json +1 -1
  89. data/templates/tool_svelte/package.json +1 -1
  90. data/templates/tool_vue/package.json +3 -3
  91. data/templates/widget/package.json +2 -2
  92. metadata +7 -3
  93. data/templates/tool_vue/.nycrc +0 -3
  94. data/templates/widget/.nycrc +0 -3
@@ -43,6 +43,38 @@ module OpenC3
43
43
  class ScopeModel < Model
44
44
  PRIMARY_KEY = "openc3_scopes"
45
45
 
46
+ # Default thresholds used by the System Health tool and the metrics
47
+ # microservices. Stored in the 'system_health' setting as a JSON string.
48
+ SYSTEM_HEALTH_DEFAULTS = {
49
+ "cpu" => {
50
+ "redThreshold" => 90.0,
51
+ "yellowThreshold" => 80.0,
52
+ "snoozeMinutes" => 15,
53
+ "lastTriggerTimeRed" => nil, # timestamp or nil
54
+ "lastTriggerTimeYellow" => nil, # timestamp or nil
55
+ "sustainedSeconds" => 15
56
+ },
57
+ "memory" => {
58
+ "redThreshold" => 90.0,
59
+ "yellowThreshold" => 80.0,
60
+ "snoozeMinutes" => 15,
61
+ "lastTriggerTimeRed" => nil, # timestamp or nil
62
+ "lastTriggerTimeYellow" => nil, # timestamp or nil
63
+ "sustainedSeconds" => 15
64
+ },
65
+ "disk" => {
66
+ "redThreshold" => 90.0,
67
+ "yellowThreshold" => 80.0,
68
+ "snoozeMinutes" => 720, # 12 hours
69
+ "lastTriggerTimeRed" => nil, # timestamp or nil
70
+ "lastTriggerTimeYellow" => nil, # timestamp or nil
71
+ "sustainedSeconds" => 60
72
+ },
73
+ "global" => {
74
+ "enableAlerts" => true
75
+ }
76
+ }
77
+
46
78
  attr_accessor :children
47
79
  attr_accessor :text_log_cycle_time
48
80
  attr_accessor :text_log_cycle_size
@@ -407,36 +439,8 @@ module OpenC3
407
439
  SettingModel.set({name: "news_feed", data: true}, scope: @scope)
408
440
 
409
441
  setting = SettingModel.get(name: "system_health")
410
- system_health_data = {
411
- "cpu" => {
412
- "redThreshold" => 90.0,
413
- "yellowThreshold" => 80.0,
414
- "snoozeMinutes" => 15,
415
- "lastTriggerTimeRed" => nil, # timestamp or nil
416
- "lastTriggerTimeYellow" => nil, # timestamp or nil
417
- "sustainedSeconds" => 15
418
- },
419
- "memory" => {
420
- "redThreshold" => 90.0,
421
- "yellowThreshold" => 80.0,
422
- "snoozeMinutes" => 15,
423
- "lastTriggerTimeRed" => nil, # timestamp or nil
424
- "lastTriggerTimeYellow" => nil, # timestamp or nil
425
- "sustainedSeconds" => 15
426
- },
427
- "disk" => {
428
- "redThreshold" => 90.0,
429
- "yellowThreshold" => 80.0,
430
- "snoozeMinutes" => 720, # 12 hours
431
- "lastTriggerTimeRed" => nil, # timestamp or nil
432
- "lastTriggerTimeYellow" => nil, # timestamp or nil
433
- "sustainedSeconds" => 60
434
- },
435
- "global" => {
436
- "enableAlerts" => true
437
- }
438
- }
439
- SettingModel.set({name: "system_health", data: system_health_data}, scope: "DEFAULT") unless setting
442
+ # Settings are stored as JSON strings
443
+ SettingModel.set({name: "system_health", data: JSON.generate(SYSTEM_HEALTH_DEFAULTS)}, scope: "DEFAULT") unless setting
440
444
  end
441
445
  end
442
446
  end
@@ -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
 
@@ -30,6 +30,7 @@ require 'openc3/topics/config_topic'
30
30
  require 'openc3/system'
31
31
  require 'openc3/utilities/local_mode'
32
32
  require 'openc3/utilities/bucket'
33
+ require 'openc3/utilities/target_file'
33
34
  require 'openc3/utilities/zip'
34
35
  require 'fileutils'
35
36
  require 'ostruct'
@@ -50,6 +51,7 @@ module OpenC3
50
51
  ERB_EXTENSIONS = %w(.txt .rb .py .json .yaml .yml)
51
52
  ITEM_MAP_CACHE_TIMEOUT = 10.0
52
53
  PACKET_CACHE_TIMEOUT = 10.0
54
+ MAX_COLUMN_HEADER_LENGTH = 127 # QuestDB column header limit for item/param names
53
55
  @@item_map_cache = {}
54
56
  @@packet_cache = {}
55
57
  @@packet_cache_mutex = Mutex.new
@@ -63,6 +65,7 @@ module OpenC3
63
65
  attr_accessor :ignored_parameters
64
66
  attr_accessor :ignored_items
65
67
  attr_accessor :limits_groups
68
+ attr_accessor :stored_limits_mode
66
69
  attr_accessor :cmd_tlm_files
67
70
  attr_accessor :id
68
71
  attr_accessor :cmd_buffer_depth
@@ -75,6 +78,7 @@ module OpenC3
75
78
  attr_accessor :tlm_log_retain_time
76
79
  attr_accessor :cmd_decom_retain_time
77
80
  attr_accessor :tlm_decom_retain_time
81
+ attr_accessor :decom_flush_period
78
82
  attr_accessor :cleanup_poll_time
79
83
  attr_accessor :needs_dependencies
80
84
  attr_accessor :target_microservices
@@ -122,6 +126,63 @@ module OpenC3
122
126
  targets.sort.to_h
123
127
  end
124
128
 
129
+ # Splits a plugin instance name ("openc3-cosmos-demo-7.2.0.gem__0") into
130
+ # [base_name, version] (["openc3-cosmos-demo", "7.2.0"]), or nil when the
131
+ # name doesn't carry a version segment. Drops the "__N" install-instance
132
+ # suffix and the ".gem" extension first.
133
+ def self.plugin_name_version(plugin_instance)
134
+ return nil if plugin_instance.nil? || plugin_instance.empty?
135
+ gem = plugin_instance.split('__')[0].sub(/\.gem\z/, '')
136
+ parts = gem.split('-')
137
+ return nil if parts.length < 2
138
+ [parts[0..-2].join('-'), parts[-1]]
139
+ end
140
+
141
+ # "name version" of the plugin that installed this target (e.g.
142
+ # "openc3-cosmos-demo 7.2.0"), derived from the plugin instance name
143
+ # ("openc3-cosmos-demo-7.2.0.gem__0"), or nil if unavailable. Used to
144
+ # annotate the Version History baseline with the file's origin.
145
+ def self.plugin_version_label(target_name, scope:)
146
+ model = get(name: target_name, scope: scope)
147
+ name, version = plugin_name_version(model && model['plugin'])
148
+ return nil unless name
149
+ "#{name} #{version}"
150
+ rescue
151
+ nil
152
+ end
153
+
154
+ # Version-stripped plugin base name that owns this target, e.g.
155
+ # "openc3-cosmos-demo" from instance "openc3-cosmos-demo-7.2.0.gem__0".
156
+ # Used as the per-plugin Version History repo key: stable across upgrades
157
+ # (only the version segment changes), so history survives version bumps.
158
+ # Returns nil when the target has no owning plugin (e.g. a hand-created
159
+ # target); callers fall back to a no-plugin bucket.
160
+ def self.plugin_base_name(target_name, scope:)
161
+ model = get(name: target_name, scope: scope)
162
+ name, _version = plugin_name_version(model && model['plugin'])
163
+ name
164
+ rescue
165
+ nil
166
+ end
167
+
168
+ # Uninstall-only: remove a plugin's entire Version History repo. Called
169
+ # from the CLI uninstall path with the plugin instance name; upgrade reuses
170
+ # the repo and must never call this. No-op in Core builds (or whenever
171
+ # OPENC3_VERSION_HISTORY_DIR is unset) since VersionStore is absent or
172
+ # disabled. Best-effort: a failure here must not abort the uninstall.
173
+ def self.destroy_script_versions(plugin_instance_name, scope:)
174
+ name, _version = plugin_name_version(plugin_instance_name)
175
+ return unless name
176
+ begin
177
+ require 'openc3-enterprise/utilities/version_store'
178
+ rescue LoadError
179
+ return
180
+ end
181
+ ::VersionStore.destroy_repo(scope: scope, plugin: name)
182
+ rescue => e
183
+ Logger.warn("destroy_script_versions failed for #{scope}/#{plugin_instance_name}: #{e.message}")
184
+ end
185
+
125
186
  # Given target's modified file list
126
187
  def self.modified_files(target_name, scope:)
127
188
  modified = []
@@ -144,7 +205,19 @@ module OpenC3
144
205
  modified.sort
145
206
  end
146
207
 
147
- def self.delete_modified(target_name, scope:)
208
+ # files: optional list of specific modified files to delete, each a name
209
+ # relative to scope ("TARGET/sub/path", matching modified_files output).
210
+ # When given, only those files are removed (used by plugin upgrade to drop
211
+ # some modified files while keeping others); when nil, every modified file
212
+ # for the target is removed (the original behavior).
213
+ def self.delete_modified(target_name, scope:, files: nil)
214
+ if files && !files.empty?
215
+ files.each do |name|
216
+ # TargetFile.destroy handles both local mode and the bucket.
217
+ OpenC3::TargetFile.destroy(scope, name)
218
+ end
219
+ return
220
+ end
148
221
  if ENV['OPENC3_LOCAL_MODE']
149
222
  OpenC3::LocalMode.delete_modified(target_name, scope: scope)
150
223
  end
@@ -223,7 +296,7 @@ module OpenC3
223
296
 
224
297
  # Assume it exists and just try to get it to avoid an extra call to Store.exist?
225
298
  json = store_for_target(target_name, scope: scope).hget("#{scope}__openc3#{type.to_s.downcase}__#{target_name}", packet_name)
226
- raise "Packet '#{target_name} #{packet_name}' does not exist" if json.nil?
299
+ raise "Packet definition '#{target_name} #{packet_name}' does not exist" if json.nil?
227
300
 
228
301
  packet = JSON.parse(json, allow_nan: true, create_additions: true)
229
302
 
@@ -244,7 +317,7 @@ module OpenC3
244
317
  # @return [Array<Hash>] All packet hashes under the target_name
245
318
  def self.packets(target_name, type: :TLM, scope:)
246
319
  raise "Unknown type #{type} for #{target_name}" unless VALID_TYPES.include?(type)
247
- raise "Target '#{target_name}' does not exist for scope: #{scope}" unless get(name: target_name, scope: scope)
320
+ raise "Target '#{target_name}' does not exist for scope: #{scope} (TargetModel)" unless get(name: target_name, scope: scope)
248
321
 
249
322
  result = []
250
323
  packets = store_for_target(target_name, scope: scope).hgetall("#{scope}__openc3#{type.to_s.downcase}__#{target_name}")
@@ -280,7 +353,7 @@ module OpenC3
280
353
  def self.packet_item(target_name, packet_name, item_name, type: :TLM, scope:)
281
354
  packet = packet(target_name, packet_name, type: type, scope: scope)
282
355
  item = packet['items'].find { |item| item['name'] == item_name.to_s }
283
- raise "Item '#{packet['target_name']} #{packet['packet_name']} #{item_name}' does not exist" unless item
356
+ raise "Item '#{packet['target_name']} #{packet['packet_name']} #{item_name}' does not exist (TargetModel)" unless item
284
357
  item
285
358
  end
286
359
 
@@ -384,6 +457,7 @@ module OpenC3
384
457
  ignored_parameters: [],
385
458
  ignored_items: [],
386
459
  limits_groups: [],
460
+ stored_limits_mode: 'PROCESS',
387
461
  cmd_tlm_files: [],
388
462
  id: nil,
389
463
  updated_at: nil,
@@ -398,6 +472,7 @@ module OpenC3
398
472
  tlm_log_retain_time: nil,
399
473
  cmd_decom_retain_time: nil,
400
474
  tlm_decom_retain_time: nil,
475
+ decom_flush_period: 5.0,
401
476
  cleanup_poll_time: 3600,
402
477
  needs_dependencies: false,
403
478
  target_microservices: {},
@@ -412,6 +487,8 @@ module OpenC3
412
487
  @ignored_parameters = ignored_parameters
413
488
  @ignored_items = ignored_items
414
489
  @limits_groups = limits_groups
490
+ @stored_limits_mode = stored_limits_mode.to_s.upcase
491
+ @stored_limits_mode = 'PROCESS' unless %w(PROCESS LOG DISABLE).include?(@stored_limits_mode)
415
492
  @cmd_tlm_files = cmd_tlm_files
416
493
  @id = id
417
494
  @cmd_buffer_depth = cmd_buffer_depth
@@ -424,6 +501,7 @@ module OpenC3
424
501
  @tlm_log_retain_time = tlm_log_retain_time
425
502
  @cmd_decom_retain_time = cmd_decom_retain_time
426
503
  @tlm_decom_retain_time = tlm_decom_retain_time
504
+ @decom_flush_period = decom_flush_period
427
505
  @cleanup_poll_time = cleanup_poll_time
428
506
  @needs_dependencies = needs_dependencies
429
507
  @target_microservices = target_microservices
@@ -442,6 +520,7 @@ module OpenC3
442
520
  'ignored_parameters' => @ignored_parameters,
443
521
  'ignored_items' => @ignored_items,
444
522
  'limits_groups' => @limits_groups,
523
+ 'stored_limits_mode' => @stored_limits_mode,
445
524
  'cmd_tlm_files' => @cmd_tlm_files,
446
525
  'id' => @id,
447
526
  'updated_at' => @updated_at,
@@ -456,6 +535,7 @@ module OpenC3
456
535
  'tlm_log_retain_time' => @tlm_log_retain_time,
457
536
  'cmd_decom_retain_time' => @cmd_decom_retain_time,
458
537
  'tlm_decom_retain_time' => @tlm_decom_retain_time,
538
+ 'decom_flush_period' => @decom_flush_period,
459
539
  'cleanup_poll_time' => @cleanup_poll_time,
460
540
  'needs_dependencies' => @needs_dependencies,
461
541
  'target_microservices' => @target_microservices.as_json(),
@@ -510,6 +590,9 @@ module OpenC3
510
590
  if @tlm_decom_retain_time and !@tlm_decom_retain_time.match?(/^\d+[hdwMy]$/)
511
591
  raise ConfigParser::Error.new(parser, "TLM_DECOM_RETAIN_TIME must be a number followed by h, d, w, M, or y (e.g., 24h, 7d, 1y)")
512
592
  end
593
+ when 'DECOM_FLUSH_PERIOD'
594
+ parser.verify_num_parameters(1, 1, "#{keyword} <Flush period in seconds>")
595
+ @decom_flush_period = Float(parameters[0])
513
596
  when 'REDUCED_MINUTE_LOG_RETAIN_TIME', 'REDUCED_HOUR_LOG_RETAIN_TIME', 'REDUCED_DAY_LOG_RETAIN_TIME', 'REDUCED_LOG_RETAIN_TIME'
514
597
  # DEPRECATED
515
598
  when 'REDUCER_DISABLE', 'REDUCER_DISABLED', 'REDUCER_MAX_CPU_UTILIZATION', 'REDUCED_MAX_CPU_UTILIZATION'
@@ -557,17 +640,37 @@ module OpenC3
557
640
  parser.verify_num_parameters(1, 1, "#{keyword} <Shard Number Starting from 0>")
558
641
  @db_shard = Integer(parameters[0])
559
642
 
643
+ when 'STORED_LIMITS_MODE'
644
+ parser.verify_num_parameters(1, 1, "#{keyword} <PROCESS, LOG, or DISABLE>")
645
+ mode = parameters[0].to_s.upcase
646
+ unless %w(PROCESS LOG DISABLE).include?(mode)
647
+ raise ConfigParser::Error.new(parser, "STORED_LIMITS_MODE must be one of PROCESS, LOG, or DISABLE")
648
+ end
649
+ @stored_limits_mode = mode
650
+
560
651
  else
561
652
  raise ConfigParser::Error.new(parser, "Unknown keyword and parameters for Target: #{keyword} #{parameters.join(" ")}")
562
653
  end
563
654
  return nil
564
655
  end
565
656
 
566
- def deploy(gem_path, variables, validate_only: false)
657
+ # upgrade_context (plugin upgrades only) drives two modes, both keyed by
658
+ # the deployed file name "TARGET/sub/path" (matches modified_files output):
659
+ # { diff_collector: [] } — dry run (with validate_only): append the names
660
+ # of modified files whose live content differs from the rendered plugin
661
+ # content. Read-only; used to warn before an upgrade.
662
+ # { username:, plugin: "name version", version_files: [<name>...] } — for
663
+ # each listed file with a modified copy: record the modified copy in
664
+ # Version History, drop the shadow so the plugin content becomes current,
665
+ # then commit the incoming content as a plugin-upgrade version.
666
+ # nil (the default and Core builds without VersionStore) = no-op.
667
+ def deploy(gem_path, variables, validate_only: false, upgrade_context: nil)
567
668
  variables["target_name"] = @name
568
669
  start_path = "/targets/#{@folder_name}/"
569
670
  temp_dir = Dir.mktmpdir
570
671
  found = false
672
+ diff_collector = upgrade_context && upgrade_context[:diff_collector]
673
+ versioning = !validate_only && upgrade_context && upgrade_context[:version_files] && version_store_available?
571
674
  begin
572
675
  target_path = gem_path + start_path + "**/*"
573
676
  Dir.glob(target_path) do |filename|
@@ -603,15 +706,30 @@ module OpenC3
603
706
  File.open(local_path, 'wb') { |file| file.write(data) }
604
707
  found = true
605
708
  @bucket.put_object(bucket: ENV['OPENC3_CONFIG_BUCKET'], key: key, body: data) unless validate_only
709
+
710
+ # Plugin upgrade: either collect this file into the diff (dry run) or
711
+ # take it from the plugin while preserving the modified copy in
712
+ # Version History. Both skip files with no modified copy.
713
+ name = "#{@name}/#{target_folder_path}"
714
+ if diff_collector
715
+ collect_modified_diff(name, data, diff_collector)
716
+ elsif versioning && upgrade_context[:version_files].include?(name)
717
+ apply_upgrade_version(name, data, upgrade_context)
718
+ end
606
719
  end
607
720
  raise "No target files found at #{target_path}" unless found
608
721
 
609
722
  target_folder = File.join(temp_dir, @name)
610
723
  # Build a System for just this target
611
724
  system = System.new([@name], temp_dir)
725
+ check_column_header_lengths(system)
612
726
  if variables["xtce_output"]
613
727
  puts "Converting target #{@name} to .xtce files in #{variables["xtce_output"]}/#{@name}"
614
- puts " Using mnemonic '#{variables['time_association_name']}' as the packet time item."
728
+ if variables['time_association_name'].to_s.empty?
729
+ puts " No packet time item given, so no TimeAssociation will be written."
730
+ else
731
+ puts " Using mnemonic '#{variables['time_association_name']}' as the packet time item."
732
+ end
615
733
  system.packet_config.to_xtce(variables["xtce_output"], variables['time_association_name'])
616
734
  end
617
735
  unless validate_only
@@ -625,6 +743,74 @@ module OpenC3
625
743
  end
626
744
  end
627
745
 
746
+ # Version History is an Enterprise feature. The plugin deploy runs in the
747
+ # cmd-tlm-api process, which (unlike script-runner-api) doesn't otherwise
748
+ # require the store, so lazily load it on demand. Returns false in Core
749
+ # builds where the Enterprise gem isn't present.
750
+ def version_store_available?
751
+ return true if defined?(::VersionStore)
752
+ require 'openc3-enterprise/utilities/version_store'
753
+ defined?(::VersionStore) ? true : false
754
+ rescue LoadError
755
+ false
756
+ end
757
+
758
+ # See deploy/upgrade_context. Reads the modified shadow directly (not
759
+ # TargetFile.body, which would fall back to the just-overwritten pristine
760
+ # copy), commits it, removes it, then commits the incoming plugin content.
761
+ # Best-effort: a versioning failure must not abort the plugin upgrade.
762
+ def apply_upgrade_version(name, new_data, ctx)
763
+ modified_key = "#{@scope}/targets_modified/#{name}"
764
+ resp = @bucket.get_object(bucket: ENV['OPENC3_CONFIG_BUCKET'], key: modified_key)
765
+ return unless resp && resp.body
766
+ old_body = resp.body.read
767
+ # Nothing actually changed — the modification already matches the plugin.
768
+ # Leave the (identical) shadow in place and create no version churn.
769
+ return if old_body.b == new_data.b
770
+ old_body = old_body.force_encoding('UTF-8') unless File.extname(name) == '.bin'
771
+ # Preserve the pre-upgrade (user-modified) content as a version.
772
+ # Idempotent: a no-op when it already matches the latest version.
773
+ ::VersionStore.commit(scope: @scope, name: name, text: old_body)
774
+ # Drop the modified shadow so the plugin file (already written to
775
+ # targets/) becomes the live content.
776
+ OpenC3::TargetFile.destroy(@scope, name)
777
+ # Commit the incoming plugin content as a plugin-upgrade version,
778
+ # attributed to the installing user.
779
+ ::VersionStore.commit(scope: @scope, name: name, text: new_data,
780
+ username: ctx[:username], source: 'plugin-upgrade', plugin: ctx[:plugin])
781
+ rescue => e
782
+ Logger.warn("Version History upgrade capture failed for #{@scope}/#{name}: #{e.message}")
783
+ end
784
+
785
+ # Dry-run companion to apply_upgrade_version: append name to collector when
786
+ # a modified copy exists and differs (by bytes) from the rendered plugin
787
+ # content. Read-only.
788
+ def collect_modified_diff(name, new_data, collector)
789
+ resp = @bucket.get_object(bucket: ENV['OPENC3_CONFIG_BUCKET'], key: "#{@scope}/targets_modified/#{name}")
790
+ return unless resp && resp.body
791
+ collector << name if resp.body.read.b != new_data.b
792
+ rescue => e
793
+ Logger.warn("Modified diff check failed for #{@scope}/#{name}: #{e.message}")
794
+ end
795
+
796
+ def check_column_header_lengths(system)
797
+ too_long = []
798
+ [system.packet_config.telemetry, system.packet_config.commands].each do |packets_by_target|
799
+ packets_by_target.each do |target_name, packets|
800
+ packets.each do |packet_name, packet|
801
+ packet.sorted_items.each do |item|
802
+ if item.name.length > MAX_COLUMN_HEADER_LENGTH
803
+ too_long << "#{target_name} #{packet_name} #{item.name} (#{item.name.length})"
804
+ end
805
+ end
806
+ end
807
+ end
808
+ end
809
+ unless too_long.empty?
810
+ raise "Item / parameter names must be #{MAX_COLUMN_HEADER_LENGTH} characters or less (QuestDB column header limit). The following are too long:\n #{too_long.join("\n ")}"
811
+ end
812
+ end
813
+
628
814
  def undeploy
629
815
  prefix = "#{@scope}/targets/#{@name}/"
630
816
  objects = @bucket.list_objects(bucket: ENV['OPENC3_CONFIG_BUCKET'], prefix: prefix)
@@ -1019,6 +1205,7 @@ module OpenC3
1019
1205
  options = []
1020
1206
  options << ["CMD_DECOM_RETAIN_TIME", @cmd_decom_retain_time] if @cmd_decom_retain_time
1021
1207
  options << ["TLM_DECOM_RETAIN_TIME", @tlm_decom_retain_time] if @tlm_decom_retain_time
1208
+ options << ["DECOM_FLUSH_PERIOD", @decom_flush_period] if @decom_flush_period
1022
1209
  microservice = MicroserviceModel.new(
1023
1210
  name: microservice_name,
1024
1211
  folder_name: @folder_name,