openc3 7.2.1 → 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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +97 -6
  3. data/bin/pipinstall +5 -4
  4. data/bin/pipuninstall +5 -1
  5. data/bin/uvinstall +149 -0
  6. data/data/config/item_modifiers.yaml +2 -1
  7. data/data/config/microservice.yaml +5 -1
  8. data/data/config/screen.yaml +2 -1
  9. data/data/config/target.yaml +21 -0
  10. data/data/config/tool.yaml +3 -1
  11. data/data/config/widgets.yaml +16 -3
  12. data/data/xtce_schemas/SpaceSystem_20180204.xsd +5918 -0
  13. data/data/xtce_schemas/xml.xsd +116 -0
  14. data/ext/openc3/ext/telemetry/telemetry.c +1 -1
  15. data/lib/openc3/accessors/template_accessor.rb +2 -2
  16. data/lib/openc3/api/interface_api.rb +2 -2
  17. data/lib/openc3/api/limits_api.rb +27 -13
  18. data/lib/openc3/api/router_api.rb +3 -3
  19. data/lib/openc3/api/tlm_api.rb +1 -1
  20. data/lib/openc3/interfaces/http_client_interface.rb +1 -1
  21. data/lib/openc3/io/json_drb.rb +24 -8
  22. data/lib/openc3/io/json_rpc.rb +0 -2
  23. data/lib/openc3/logs/log_writer.rb +43 -7
  24. data/lib/openc3/logs/packet_log_writer.rb +1 -2
  25. data/lib/openc3/microservices/interface_microservice.rb +1 -1
  26. data/lib/openc3/migrations/20260731000000_system_health_json.rb +41 -0
  27. data/lib/openc3/models/cvt_model.rb +5 -5
  28. data/lib/openc3/models/gem_model.rb +1 -1
  29. data/lib/openc3/models/interface_model.rb +1 -1
  30. data/lib/openc3/models/microservice_model.rb +21 -0
  31. data/lib/openc3/models/plugin_model.rb +204 -50
  32. data/lib/openc3/models/python_package_model.rb +207 -11
  33. data/lib/openc3/models/scope_model.rb +34 -30
  34. data/lib/openc3/models/target_model.rb +180 -6
  35. data/lib/openc3/models/tool_model.rb +33 -20
  36. data/lib/openc3/operators/microservice_operator.rb +33 -4
  37. data/lib/openc3/packets/commands.rb +4 -4
  38. data/lib/openc3/packets/limits.rb +15 -7
  39. data/lib/openc3/packets/packet.rb +1 -1
  40. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  41. data/lib/openc3/packets/parsers/xtce_converter.rb +183 -103
  42. data/lib/openc3/packets/parsers/xtce_parser.rb +71 -23
  43. data/lib/openc3/packets/structure.rb +2 -2
  44. data/lib/openc3/packets/telemetry.rb +3 -3
  45. data/lib/openc3/script/commands.rb +1 -1
  46. data/lib/openc3/script/extract.rb +20 -25
  47. data/lib/openc3/script/web_socket_api.rb +44 -4
  48. data/lib/openc3/topics/command_decom_topic.rb +16 -1
  49. data/lib/openc3/topics/limits_event_topic.rb +1 -1
  50. data/lib/openc3/utilities/aws_bucket.rb +10 -2
  51. data/lib/openc3/utilities/csv.rb +5 -5
  52. data/lib/openc3/utilities/local_mode.rb +4 -1
  53. data/lib/openc3/utilities/ruby_lex_utils.rb +5 -1
  54. data/lib/openc3/utilities/running_script.rb +145 -90
  55. data/lib/openc3/utilities/script.rb +46 -10
  56. data/lib/openc3/version.rb +6 -6
  57. data/templates/plugin/plugin.gemspec +1 -1
  58. data/templates/tool_angular/package.json +2 -2
  59. data/templates/tool_react/package.json +1 -1
  60. data/templates/tool_svelte/package.json +1 -1
  61. data/templates/tool_vue/package.json +3 -3
  62. data/templates/widget/package.json +2 -2
  63. metadata +5 -3
  64. data/templates/tool_vue/.nycrc +0 -3
  65. 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
@@ -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
@@ -76,6 +78,7 @@ module OpenC3
76
78
  attr_accessor :tlm_log_retain_time
77
79
  attr_accessor :cmd_decom_retain_time
78
80
  attr_accessor :tlm_decom_retain_time
81
+ attr_accessor :decom_flush_period
79
82
  attr_accessor :cleanup_poll_time
80
83
  attr_accessor :needs_dependencies
81
84
  attr_accessor :target_microservices
@@ -123,6 +126,63 @@ module OpenC3
123
126
  targets.sort.to_h
124
127
  end
125
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
+
126
186
  # Given target's modified file list
127
187
  def self.modified_files(target_name, scope:)
128
188
  modified = []
@@ -145,7 +205,19 @@ module OpenC3
145
205
  modified.sort
146
206
  end
147
207
 
148
- 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
149
221
  if ENV['OPENC3_LOCAL_MODE']
150
222
  OpenC3::LocalMode.delete_modified(target_name, scope: scope)
151
223
  end
@@ -224,7 +296,7 @@ module OpenC3
224
296
 
225
297
  # Assume it exists and just try to get it to avoid an extra call to Store.exist?
226
298
  json = store_for_target(target_name, scope: scope).hget("#{scope}__openc3#{type.to_s.downcase}__#{target_name}", packet_name)
227
- 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?
228
300
 
229
301
  packet = JSON.parse(json, allow_nan: true, create_additions: true)
230
302
 
@@ -245,7 +317,7 @@ module OpenC3
245
317
  # @return [Array<Hash>] All packet hashes under the target_name
246
318
  def self.packets(target_name, type: :TLM, scope:)
247
319
  raise "Unknown type #{type} for #{target_name}" unless VALID_TYPES.include?(type)
248
- 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)
249
321
 
250
322
  result = []
251
323
  packets = store_for_target(target_name, scope: scope).hgetall("#{scope}__openc3#{type.to_s.downcase}__#{target_name}")
@@ -281,7 +353,7 @@ module OpenC3
281
353
  def self.packet_item(target_name, packet_name, item_name, type: :TLM, scope:)
282
354
  packet = packet(target_name, packet_name, type: type, scope: scope)
283
355
  item = packet['items'].find { |item| item['name'] == item_name.to_s }
284
- 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
285
357
  item
286
358
  end
287
359
 
@@ -400,6 +472,7 @@ module OpenC3
400
472
  tlm_log_retain_time: nil,
401
473
  cmd_decom_retain_time: nil,
402
474
  tlm_decom_retain_time: nil,
475
+ decom_flush_period: 5.0,
403
476
  cleanup_poll_time: 3600,
404
477
  needs_dependencies: false,
405
478
  target_microservices: {},
@@ -428,6 +501,7 @@ module OpenC3
428
501
  @tlm_log_retain_time = tlm_log_retain_time
429
502
  @cmd_decom_retain_time = cmd_decom_retain_time
430
503
  @tlm_decom_retain_time = tlm_decom_retain_time
504
+ @decom_flush_period = decom_flush_period
431
505
  @cleanup_poll_time = cleanup_poll_time
432
506
  @needs_dependencies = needs_dependencies
433
507
  @target_microservices = target_microservices
@@ -461,6 +535,7 @@ module OpenC3
461
535
  'tlm_log_retain_time' => @tlm_log_retain_time,
462
536
  'cmd_decom_retain_time' => @cmd_decom_retain_time,
463
537
  'tlm_decom_retain_time' => @tlm_decom_retain_time,
538
+ 'decom_flush_period' => @decom_flush_period,
464
539
  'cleanup_poll_time' => @cleanup_poll_time,
465
540
  'needs_dependencies' => @needs_dependencies,
466
541
  'target_microservices' => @target_microservices.as_json(),
@@ -515,6 +590,9 @@ module OpenC3
515
590
  if @tlm_decom_retain_time and !@tlm_decom_retain_time.match?(/^\d+[hdwMy]$/)
516
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)")
517
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])
518
596
  when 'REDUCED_MINUTE_LOG_RETAIN_TIME', 'REDUCED_HOUR_LOG_RETAIN_TIME', 'REDUCED_DAY_LOG_RETAIN_TIME', 'REDUCED_LOG_RETAIN_TIME'
519
597
  # DEPRECATED
520
598
  when 'REDUCER_DISABLE', 'REDUCER_DISABLED', 'REDUCER_MAX_CPU_UTILIZATION', 'REDUCED_MAX_CPU_UTILIZATION'
@@ -576,11 +654,23 @@ module OpenC3
576
654
  return nil
577
655
  end
578
656
 
579
- 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)
580
668
  variables["target_name"] = @name
581
669
  start_path = "/targets/#{@folder_name}/"
582
670
  temp_dir = Dir.mktmpdir
583
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?
584
674
  begin
585
675
  target_path = gem_path + start_path + "**/*"
586
676
  Dir.glob(target_path) do |filename|
@@ -616,15 +706,30 @@ module OpenC3
616
706
  File.open(local_path, 'wb') { |file| file.write(data) }
617
707
  found = true
618
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
619
719
  end
620
720
  raise "No target files found at #{target_path}" unless found
621
721
 
622
722
  target_folder = File.join(temp_dir, @name)
623
723
  # Build a System for just this target
624
724
  system = System.new([@name], temp_dir)
725
+ check_column_header_lengths(system)
625
726
  if variables["xtce_output"]
626
727
  puts "Converting target #{@name} to .xtce files in #{variables["xtce_output"]}/#{@name}"
627
- 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
628
733
  system.packet_config.to_xtce(variables["xtce_output"], variables['time_association_name'])
629
734
  end
630
735
  unless validate_only
@@ -638,6 +743,74 @@ module OpenC3
638
743
  end
639
744
  end
640
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
+
641
814
  def undeploy
642
815
  prefix = "#{@scope}/targets/#{@name}/"
643
816
  objects = @bucket.list_objects(bucket: ENV['OPENC3_CONFIG_BUCKET'], prefix: prefix)
@@ -1032,6 +1205,7 @@ module OpenC3
1032
1205
  options = []
1033
1206
  options << ["CMD_DECOM_RETAIN_TIME", @cmd_decom_retain_time] if @cmd_decom_retain_time
1034
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
1035
1209
  microservice = MicroserviceModel.new(
1036
1210
  name: microservice_name,
1037
1211
  folder_name: @folder_name,
@@ -25,6 +25,13 @@ module OpenC3
25
25
  class ToolModel < Model
26
26
  PRIMARY_KEY = 'openc3_tools'
27
27
 
28
+ # Tools are global to the entire COSMOS installation and are always stored
29
+ # in the DEFAULT scope. The frontend navigation always requests the tool
30
+ # list from DEFAULT, so a tool installed into any other scope would be
31
+ # inaccessible. Every scope parameter in this class is therefore ignored
32
+ # and replaced with TOOL_SCOPE.
33
+ TOOL_SCOPE = 'DEFAULT'
34
+
28
35
  attr_accessor :folder_name
29
36
  attr_accessor :icon
30
37
  attr_accessor :url
@@ -39,21 +46,24 @@ module OpenC3
39
46
 
40
47
  # NOTE: The following three class methods are used by the ModelController
41
48
  # and are reimplemented to enable various Model class methods to work
49
+ # NOTE: scope is deprecated since 7.3.0 as tools always live in the DEFAULT scope, see TOOL_SCOPE
42
50
  def self.get(name:, scope: nil)
43
- super("#{scope}__#{PRIMARY_KEY}", name: name)
51
+ super("#{TOOL_SCOPE}__#{PRIMARY_KEY}", name: name)
44
52
  end
45
53
 
54
+ # NOTE: scope is deprecated since 7.3.0 as tools always live in the DEFAULT scope, see TOOL_SCOPE
46
55
  def self.names(scope: nil)
47
56
  array = []
48
- all(scope: scope).each do |name, _tool|
57
+ all().each do |name, _tool|
49
58
  array << name
50
59
  end
51
60
  array
52
61
  end
53
62
 
63
+ # NOTE: scope is deprecated since 7.3.0 as tools always live in the DEFAULT scope, see TOOL_SCOPE
54
64
  def self.all(scope: nil)
55
65
  ordered_array = []
56
- tools = unordered_all(scope: scope)
66
+ tools = unordered_all()
57
67
  tools.each do |_name, tool|
58
68
  ordered_array << tool
59
69
  end
@@ -65,14 +75,10 @@ module OpenC3
65
75
  ordered_hash
66
76
  end
67
77
 
78
+ # Tools only exist in the DEFAULT scope. Kept for backwards compatibility
79
+ # with the ToolsController importmap endpoint.
68
80
  def self.all_scopes
69
- result = {}
70
- scopes = OpenC3::ScopeModel.all
71
- scopes.each do |key, _scope|
72
- tools = unordered_all(scope: key)
73
- result.merge!(tools)
74
- end
75
- result
81
+ unordered_all()
76
82
  end
77
83
 
78
84
  # Called by the PluginModel to allow this class to validate it's top-level keyword: "TOOL"
@@ -80,7 +86,8 @@ module OpenC3
80
86
  case keyword
81
87
  when 'TOOL'
82
88
  parser.verify_num_parameters(2, 2, "TOOL <Folder Name> <Name>")
83
- return self.new(folder_name: parameters[0], name: parameters[1], plugin: plugin, needs_dependencies: needs_dependencies, scope: scope)
89
+ # NOTE: scope is intentionally ignored, see TOOL_SCOPE
90
+ return self.new(folder_name: parameters[0], name: parameters[1], plugin: plugin, needs_dependencies: needs_dependencies, scope: TOOL_SCOPE)
84
91
  else
85
92
  raise ConfigParser::Error.new(parser, "Unknown keyword and parameters for Tool: #{keyword} #{parameters.join(" ")}")
86
93
  end
@@ -89,8 +96,10 @@ module OpenC3
89
96
 
90
97
  # The ToolsTab.vue calls the ToolsController which uses this method to reorder the tools
91
98
  # Position is index in the list starting with 0 = first
92
- def self.set_position(name:, position:, scope:)
93
- moving = from_json(get(name: name, scope: scope), scope: scope)
99
+ # NOTE: scope is deprecated since 7.3.0 as tools always live in the DEFAULT scope, see TOOL_SCOPE
100
+ def self.set_position(name:, position:, scope: nil)
101
+ tool_scope = TOOL_SCOPE
102
+ moving = from_json(get(name: name, scope: tool_scope), scope: tool_scope)
94
103
  old_pos = moving.position
95
104
  new_pos = position
96
105
  direction = :down
@@ -101,8 +110,8 @@ module OpenC3
101
110
  end
102
111
 
103
112
  # Go through all the tools and reorder
104
- all(scope: scope).each do |_tool_name, tool|
105
- tool_model = from_json(tool, scope: scope)
113
+ all(scope: tool_scope).each do |_tool_name, tool|
114
+ tool_model = from_json(tool, scope: tool_scope)
106
115
  # Update the requested model to the new position
107
116
  if tool_model.name == name
108
117
  tool_model.position = position
@@ -134,9 +143,11 @@ module OpenC3
134
143
  needs_dependencies: false,
135
144
  disable_erb: nil,
136
145
  import_map_items: nil,
137
- scope:
146
+ scope: nil # NOTE: deprecated since 7.3.0 as tools are always created in the DEFAULT scope, see TOOL_SCOPE
138
147
  )
139
- super("#{scope}__#{PRIMARY_KEY}", name: name, plugin: plugin, updated_at: updated_at, scope: scope)
148
+ # Tools are always created in the DEFAULT scope regardless of the scope
149
+ # the plugin is being installed into, see TOOL_SCOPE
150
+ super("#{TOOL_SCOPE}__#{PRIMARY_KEY}", name: name, plugin: plugin, updated_at: updated_at, scope: TOOL_SCOPE)
140
151
  @folder_name = folder_name
141
152
  @icon = icon
142
153
  @url = url
@@ -156,14 +167,15 @@ module OpenC3
156
167
  end
157
168
 
158
169
  def create(update: false, force: false, queued: false)
159
- tools = self.class.all(scope: @scope)
170
+ tools = self.class.all()
160
171
 
161
172
  # Make sure a tool with this folder_name doesn't already exist
173
+ # NOTE: Tools are global (TOOL_SCOPE) so this check is across all scopes
162
174
  unless update
163
175
  if @folder_name
164
176
  tools.each do |_tool_name, tool|
165
177
  if tool['folder_name'] == @folder_name
166
- raise "Tool with folder_name #{@folder_name} already exists at create"
178
+ raise "Tool with folder_name #{@folder_name} already exists at create. Tools are global to the entire COSMOS installation and can only be installed once."
167
179
  end
168
180
  end
169
181
  end
@@ -308,8 +320,9 @@ module OpenC3
308
320
  ##################################################
309
321
 
310
322
  # Returns the list of tools or the default OpenC3 tool set if no tools have been created
323
+ # NOTE: scope is deprecated since 7.3.0 as tools always live in the DEFAULT scope, see TOOL_SCOPE
311
324
  def self.unordered_all(scope: nil)
312
- tools = Store.hgetall("#{scope}__#{PRIMARY_KEY}")
325
+ tools = Store.hgetall("#{TOOL_SCOPE}__#{PRIMARY_KEY}")
313
326
  tools.each do |key, value|
314
327
  tools[key] = JSON.parse(value, allow_nan: true, create_additions: true)
315
328
  end
@@ -47,10 +47,39 @@ module OpenC3
47
47
  env = microservice_config["env"].dup
48
48
  if microservice_config["needs_dependencies"]
49
49
  env['GEM_HOME'] = '/gems'
50
- env['PYTHONUSERBASE'] = '/gems/python_packages'
51
- # Ensure PYTHONPATH includes both the UV venv (for base openc3 module) and user packages
52
- # This is critical for UV-based Python installations where openc3 is installed as editable
53
- env['PYTHONPATH'] = "#{ENV['PYTHONPATH']}"
50
+
51
+ # Resolve the Python virtual environment for this microservice.
52
+ # If the microservice belongs to a plugin that has a per-plugin UV venv
53
+ # (created by uvinstall during plugin install), use that isolated venv.
54
+ # Otherwise fall back to the shared PYTHONUSERBASE for legacy plugins.
55
+ plugin_name = microservice_config["plugin"]
56
+ plugin_venv_dir = nil
57
+ if plugin_name
58
+ scope = microservice_name.split("__")[0]
59
+ sanitized_name = "#{scope}__#{plugin_name}".tr('^a-zA-Z0-9_-', '_')
60
+ candidate = "/gems/plugin_venvs/#{sanitized_name}/.venv"
61
+ plugin_venv_dir = candidate if File.directory?(candidate)
62
+ end
63
+
64
+ if plugin_venv_dir
65
+ env['VIRTUAL_ENV'] = plugin_venv_dir
66
+ env['PATH'] = "#{plugin_venv_dir}/bin:#{ENV.fetch('PATH', '')}"
67
+ env['PYTHONUSERBASE'] = plugin_venv_dir
68
+ # Add the plugin venv's site-packages to PYTHONPATH so the base venv's
69
+ # Python binary can find plugin-specific packages. We keep the base binary
70
+ # because it has openc3 core installed; PYTHONPATH is always respected by
71
+ # CPython regardless of venv activation state.
72
+ site_packages = Dir.glob("#{plugin_venv_dir}/lib/python*/site-packages").first
73
+ existing_pythonpath = ENV.fetch('PYTHONPATH', '')
74
+ if site_packages
75
+ env['PYTHONPATH'] = existing_pythonpath.empty? ? site_packages : "#{site_packages}:#{existing_pythonpath}"
76
+ else
77
+ env['PYTHONPATH'] = existing_pythonpath.empty? ? nil : existing_pythonpath
78
+ end
79
+ else
80
+ env['PYTHONUSERBASE'] = '/gems/python_packages'
81
+ env['PYTHONPATH'] = ENV.fetch('PYTHONPATH', nil)
82
+ end
54
83
  else
55
84
  env['GEM_HOME'] = nil
56
85
  env['PYTHONUSERBASE'] = nil
@@ -61,7 +61,7 @@ module OpenC3
61
61
  # target name keyed by the packet name
62
62
  def packets(target_name)
63
63
  target_packets = @config.commands[target_name.to_s.upcase]
64
- raise "Command target '#{target_name.to_s.upcase}' does not exist" unless target_packets
64
+ raise "Command target '#{target_name.to_s.upcase}' does not exist (packets lookup)" unless target_packets
65
65
 
66
66
  target_packets
67
67
  end
@@ -73,7 +73,7 @@ module OpenC3
73
73
  def packet(target_name, packet_name)
74
74
  target_packets = packets(target_name)
75
75
  packet = target_packets[packet_name.to_s.upcase]
76
- raise "Command packet '#{target_name.to_s.upcase} #{packet_name.to_s.upcase}' does not exist" unless packet
76
+ raise "Command packet '#{target_name.to_s.upcase} #{packet_name.to_s.upcase}' does not exist (packet lookup)" unless packet
77
77
 
78
78
  packet
79
79
  end
@@ -187,9 +187,9 @@ module OpenC3
187
187
 
188
188
  # Lookup the command directly - avoid redundant upcase in packet()/packets()
189
189
  target_packets = @config.commands[target_upcase]
190
- raise "Command target '#{target_upcase}' does not exist" unless target_packets
190
+ raise "Command target '#{target_upcase}' does not exist (build_cmd)" unless target_packets
191
191
  pkt = target_packets[packet_upcase]
192
- raise "Command packet '#{target_upcase} #{packet_upcase}' does not exist" unless pkt
192
+ raise "Command packet '#{target_upcase} #{packet_upcase}' does not exist (build_cmd)" unless pkt
193
193
  # Use deep_copy to avoid shared item modifications affecting the template
194
194
  # This is critical for variable_bit_size items where handle_write_variable_bit_size
195
195
  # modifies item.bit_offset and item.array_size during writes
@@ -159,16 +159,24 @@ module OpenC3
159
159
  # @param packet_name [String] Packet Name
160
160
  # @param item_name [String] Item Name
161
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
162
+ # @param color [String or Symbol, nil] New color: GREEN, YELLOW, or RED.
163
+ # Pass nil to clear (remove) the state color.
164
+ # @return [Symbol, nil] The color that was set, or nil if the color was cleared
164
165
  def set_state_color(target_name, packet_name, item_name, state_name, color)
165
166
  packet = _get_packet(target_name, packet_name)
166
167
  item = packet.get_item(item_name)
167
- raise "Item #{target_name} #{packet_name} #{item_name} does not have any states" unless item.states
168
+ raise "Item '#{target_name} #{packet_name} #{item_name}' does not have any states" unless item.states
168
169
  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
+ raise "State '#{state_name}' does not exist for item '#{target_name} #{packet_name} #{item_name}'" unless item.states.key?(state_name)
171
+ if color.nil?
172
+ # Clear the state color while keeping state_colors as a Hash so limits
173
+ # checking (which indexes state_colors by value) continues to work.
174
+ item.state_colors.delete(state_name) if item.state_colors
175
+ packet.update_limits_items_cache(item)
176
+ return nil
177
+ end
170
178
  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)
179
+ raise "Invalid state color '#{color}'. Must be one of #{PacketItem::STATE_COLORS.join(', ')}." unless PacketItem::STATE_COLORS.include?(color)
172
180
  item.state_colors ||= {}
173
181
  item.state_colors[state_name] = color
174
182
  item.limits.enabled = true
@@ -182,10 +190,10 @@ module OpenC3
182
190
  raise "LATEST packet not valid" if packet_name.upcase == LATEST_PACKET_NAME
183
191
 
184
192
  packets = @config.telemetry[target_name.to_s.upcase]
185
- raise "Telemetry target '#{target_name.to_s.upcase}' does not exist" unless packets
193
+ raise "Telemetry target '#{target_name.to_s.upcase}' does not exist (limits lookup)" unless packets
186
194
 
187
195
  packet = packets[packet_name.to_s.upcase]
188
- raise "Telemetry packet '#{target_name.to_s.upcase} #{packet_name.to_s.upcase}' does not exist" unless packet
196
+ raise "Telemetry packet '#{target_name.to_s.upcase} #{packet_name.to_s.upcase}' does not exist (limits lookup)" unless packet
189
197
 
190
198
  return packet
191
199
  end
@@ -616,7 +616,7 @@ module OpenC3
616
616
  def get_item(name)
617
617
  return super(name)
618
618
  rescue ArgumentError
619
- raise "Packet item '#{@target_name} #{@packet_name} #{name.upcase}' does not exist"
619
+ raise "Item '#{@target_name} #{@packet_name} #{name.upcase}' does not exist (Packet)"
620
620
  end
621
621
 
622
622
  # Read an item in the packet
@@ -124,7 +124,7 @@ module OpenC3
124
124
  def get_state_colors(item)
125
125
  color = @parser.parameters[2].upcase.to_sym
126
126
  unless PacketItem::STATE_COLORS.include? color
127
- raise @parser.error("Invalid state color #{color}. Must be one of #{PacketItem::STATE_COLORS.join(' ')}.", @usage)
127
+ raise @parser.error("Invalid state color '#{color}'. Must be one of #{PacketItem::STATE_COLORS.join(', ')}.", @usage)
128
128
  end
129
129
 
130
130
  item.limits.enabled = true