openc3 7.4.0 → 7.5.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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +4 -3
  3. data/bin/pipinstall +18 -5
  4. data/bin/pipuninstall +1 -1
  5. data/bin/uvinstall +36 -8
  6. data/data/config/default_plugins.yaml +43 -0
  7. data/data/config/interface_modifiers.yaml +22 -7
  8. data/data/config/item_modifiers.yaml +14 -4
  9. data/data/config/plugins.yaml +1 -1
  10. data/data/config/widgets.yaml +1 -1
  11. data/lib/openc3/api/cmd_api.rb +10 -13
  12. data/lib/openc3/api/interface_api.rb +1 -2
  13. data/lib/openc3/api/limits_api.rb +1 -1
  14. data/lib/openc3/api/metrics_api.rb +26 -1
  15. data/lib/openc3/api/router_api.rb +1 -2
  16. data/lib/openc3/api/tlm_api.rb +21 -21
  17. data/lib/openc3/config/config_parser.rb +3 -1
  18. data/lib/openc3/core_ext/tempfile.rb +2 -0
  19. data/lib/openc3/interfaces/file_interface.rb +6 -1
  20. data/lib/openc3/interfaces/http_client_interface.rb +14 -6
  21. data/lib/openc3/interfaces/interface.rb +27 -3
  22. data/lib/openc3/interfaces/protocols/protocol.rb +8 -0
  23. data/lib/openc3/interfaces/read_queue_interface.rb +331 -0
  24. data/lib/openc3/interfaces/stream_interface.rb +22 -3
  25. data/lib/openc3/interfaces/tcpip_server_interface.rb +13 -0
  26. data/lib/openc3/interfaces/udp_interface.rb +16 -2
  27. data/lib/openc3/interfaces.rb +1 -0
  28. data/lib/openc3/io/json_api_object.rb +24 -3
  29. data/lib/openc3/io/json_drb_object.rb +3 -2
  30. data/lib/openc3/logs/log_writer.rb +13 -4
  31. data/lib/openc3/microservices/decom_microservice.rb +7 -4
  32. data/lib/openc3/microservices/interface_microservice.rb +146 -50
  33. data/lib/openc3/microservices/log_microservice.rb +1 -3
  34. data/lib/openc3/microservices/microservice.rb +25 -1
  35. data/lib/openc3/microservices/plugin_microservice.rb +32 -0
  36. data/lib/openc3/microservices/text_log_microservice.rb +1 -3
  37. data/lib/openc3/models/interface_model.rb +1 -1
  38. data/lib/openc3/models/interface_status_model.rb +6 -0
  39. data/lib/openc3/models/metric_model.rb +52 -4
  40. data/lib/openc3/models/microservice_model.rb +7 -3
  41. data/lib/openc3/models/plugin_model.rb +12 -12
  42. data/lib/openc3/models/python_package_model.rb +3 -13
  43. data/lib/openc3/models/reaction_model.rb +6 -3
  44. data/lib/openc3/models/setting_model.rb +5 -3
  45. data/lib/openc3/models/target_model.rb +83 -9
  46. data/lib/openc3/operators/microservice_operator.rb +5 -0
  47. data/lib/openc3/operators/operator.rb +69 -0
  48. data/lib/openc3/packets/packet.rb +10 -4
  49. data/lib/openc3/packets/packet_config.rb +48 -2
  50. data/lib/openc3/packets/packet_item.rb +27 -14
  51. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  52. data/lib/openc3/packets/parsers/xtce_parser.rb +8 -1
  53. data/lib/openc3/packets/structure_item.rb +3 -2
  54. data/lib/openc3/script/api_shared.rb +58 -84
  55. data/lib/openc3/script/extract.rb +215 -19
  56. data/lib/openc3/script/plugins.rb +4 -11
  57. data/lib/openc3/script/storage.rb +81 -10
  58. data/lib/openc3/script/tables.rb +5 -1
  59. data/lib/openc3/system/system.rb +9 -6
  60. data/lib/openc3/topics/command_decom_topic.rb +1 -1
  61. data/lib/openc3/topics/command_topic.rb +2 -1
  62. data/lib/openc3/topics/router_topic.rb +2 -1
  63. data/lib/openc3/topics/telemetry_decom_topic.rb +1 -1
  64. data/lib/openc3/topics/telemetry_topic.rb +3 -2
  65. data/lib/openc3/topics/topic.rb +154 -2
  66. data/lib/openc3/utilities/cli_generator.rb +67 -15
  67. data/lib/openc3/utilities/config_overlay.rb +35 -17
  68. data/lib/openc3/utilities/default_plugins.rb +33 -0
  69. data/lib/openc3/utilities/local_mode.rb +82 -46
  70. data/lib/openc3/utilities/metric.rb +16 -0
  71. data/lib/openc3/utilities/process_stats.rb +160 -0
  72. data/lib/openc3/utilities/pypi_url.rb +65 -0
  73. data/lib/openc3/utilities/questdb_client.rb +43 -46
  74. data/lib/openc3/utilities/running_script.rb +22 -18
  75. data/lib/openc3/utilities/store_autoload.rb +20 -3
  76. data/lib/openc3/utilities/store_queued.rb +37 -9
  77. data/lib/openc3/utilities/target_file.rb +57 -12
  78. data/lib/openc3/utilities/topic_lag_monitor.rb +193 -0
  79. data/lib/openc3/version.rb +5 -5
  80. data/templates/conversion/conversion.py +6 -2
  81. data/templates/conversion/conversion.rb +5 -0
  82. data/templates/plugin/.github/workflows/playwright.yml +62 -0
  83. data/templates/plugin/.github/workflows/release.yml +45 -0
  84. data/templates/plugin/.github/workflows/unit-tests.yml +35 -0
  85. data/templates/plugin/.gitignore +6 -0
  86. data/templates/plugin/README.md +114 -2
  87. data/templates/plugin/lib/PACKAGE/__init__.py +4 -0
  88. data/templates/plugin/plugin.gemspec +10 -1
  89. data/templates/plugin/pyproject.toml +119 -0
  90. data/templates/tool_angular/package.json +2 -2
  91. data/templates/tool_angular/pnpm-workspace.yaml +16 -0
  92. data/templates/tool_react/package.json +1 -1
  93. data/templates/tool_react/pnpm-workspace.yaml +16 -0
  94. data/templates/tool_svelte/package.json +2 -1
  95. data/templates/tool_svelte/pnpm-workspace.yaml +20 -0
  96. data/templates/tool_vue/package.json +3 -3
  97. data/templates/tool_vue/pnpm-workspace.yaml +16 -0
  98. data/templates/widget/package.json +2 -2
  99. data/templates/widget/pnpm-workspace.yaml +16 -0
  100. metadata +36 -1
@@ -19,6 +19,7 @@ module OpenC3
19
19
  response_body = nil
20
20
  begin
21
21
  endpoint = "/openc3-api/plugins?scope=#{scope}"
22
+ endpoint += "&exclude_default=true" unless default
22
23
  uri = URI.parse($api_server.generate_url + endpoint)
23
24
  auth = $api_server.generate_auth
24
25
 
@@ -30,19 +31,11 @@ module OpenC3
30
31
  http.request(request) do |response|
31
32
  response_body = response.body
32
33
  response.value() # Raises an HTTP error if the response is not 2xx (success)
33
- plugins = JSON.parse(response.body, allow_nan: true, create_additions: true)
34
- if default
35
- return plugins
36
- else
37
- return plugins.select do |plugin|
38
- !plugin.include?('openc3-cosmos-tool-') and !plugin.include?('openc3-tool-base') and
39
- !plugin.include?('openc3-cosmos-enterprise-tool-') and !plugin.include?('openc3-enterprise-tool-base')
40
- end
41
- end
34
+ return JSON.parse(response.body, allow_nan: true, create_additions: true)
42
35
  end
43
36
  end
44
37
  rescue => e
45
- raise "get_plugin_list failed due to #{e.formatted}\nURI:#{uri}\nResponse:\n#{response_body}"
38
+ raise "plugin_list failed due to #{e.formatted}\nURI:#{uri}\nResponse:\n#{response_body}"
46
39
  end
47
40
  end
48
41
 
@@ -65,7 +58,7 @@ module OpenC3
65
58
  end
66
59
  end
67
60
  rescue => e
68
- raise "get_plugin failed due to #{e.formatted}\nURI:#{uri}\nResponse:\n#{response_body}"
61
+ raise "plugin_get failed due to #{e.formatted}\nURI:#{uri}\nResponse:\n#{response_body}"
69
62
  end
70
63
  end
71
64
 
@@ -17,6 +17,8 @@
17
17
 
18
18
  require 'tempfile'
19
19
  require 'net/http'
20
+ require 'base64'
21
+ require 'openc3/utilities/local_mode'
20
22
 
21
23
  ENV['OPENC3_CLOUD'] ||= 'local'
22
24
 
@@ -53,6 +55,15 @@ module OpenC3
53
55
 
54
56
  upload_path = "#{scope}/targets_modified/#{path}"
55
57
 
58
+ if OpenC3::LocalMode.local_only_name?(path)
59
+ # The local mode volume is only mounted in the cluster so from outside
60
+ # write it through the API which never sends it to the bucket
61
+ return _upload_api_file(upload_path, io_or_string, scope: scope) unless $openc3_in_cluster
62
+ puts "Writing local #{upload_path}"
63
+ OpenC3::LocalMode.put_target_file(upload_path, io_or_string, scope: scope)
64
+ return nil
65
+ end
66
+
56
67
  if ENV['OPENC3_LOCAL_MODE'] and $openc3_in_cluster
57
68
  OpenC3::LocalMode.put_target_file(upload_path, io_or_string, scope: scope)
58
69
  io_or_string.rewind if io_or_string.respond_to?(:rewind)
@@ -91,22 +102,22 @@ module OpenC3
91
102
  # @param original [Boolean] Whether to get the original or modified file
92
103
  # @return [File|nil]
93
104
  def get_target_file(path, original: false, scope: $openc3_scope)
105
+ # Local only targets only exist in the local mode volume so original doesn't apply
106
+ if OpenC3::LocalMode.local_only_name?(path)
107
+ # The local mode volume is only mounted in the cluster so from outside
108
+ # read it through the API which serves it from the volume
109
+ return _get_api_file("targets_modified/#{path}", scope: scope) unless $openc3_in_cluster
110
+ return _get_local_file(path, scope: scope)
111
+ end
112
+
94
113
  part = "targets"
95
114
  part += "_modified" unless original
96
115
  # Loop to allow redo when switching from modified to original
97
116
  loop do
98
117
  begin
99
118
  if part == "targets_modified" and ENV['OPENC3_LOCAL_MODE']
100
- local_file = OpenC3::LocalMode.open_local_file(path, scope: scope)
101
- if local_file
102
- puts "Reading local #{scope}/#{part}/#{path}"
103
- file = Tempfile.new('target', binmode: true)
104
- file.filename = path
105
- file.write(local_file.read)
106
- local_file.close
107
- file.rewind
108
- return file
109
- end
119
+ file = _get_local_file(path, scope: scope)
120
+ return file if file
110
121
  end
111
122
 
112
123
  return _get_storage_file("#{part}/#{path}", scope: scope)
@@ -125,7 +136,53 @@ module OpenC3
125
136
 
126
137
  # These are helper methods ... should not be used directly
127
138
 
139
+ # Write a file through the API instead of a presigned URL
140
+ def _upload_api_file(path, io_or_string, bucket: 'OPENC3_CONFIG_BUCKET', scope: $openc3_scope)
141
+ data = String === io_or_string ? io_or_string : io_or_string.read
142
+ endpoint = "/openc3-api/storage/upload_file/#{path}"
143
+ puts "Writing #{path}"
144
+ response = $api_server.request('put', endpoint, query: { bucket: bucket },
145
+ data: { contents: Base64.strict_encode64(data) }, json: true, scope: scope)
146
+ if response.nil? || response.status != 200
147
+ raise "Failed to write #{path}"
148
+ end
149
+ nil
150
+ end
151
+
152
+ # Read a file through the API instead of a presigned URL
153
+ # Returns nil if the file doesn't exist
154
+ def _get_api_file(path, bucket: 'OPENC3_CONFIG_BUCKET', scope: $openc3_scope)
155
+ endpoint = "/openc3-api/storage/download_file/#{scope}/#{path}"
156
+ response = $api_server.request('get', endpoint, query: { bucket: bucket }, scope: scope)
157
+ return nil if response && response.status == 404
158
+ if response.nil? || response.status != 200
159
+ raise "Failed to read #{scope}/#{path}"
160
+ end
161
+ puts "Reading #{scope}/#{path}"
162
+ json = JSON.parse(response.body, allow_nan: true, create_additions: true)
163
+ file = Tempfile.new('target', binmode: true)
164
+ file.filename = path
165
+ file.write(Base64.decode64(json['contents']))
166
+ file.rewind
167
+ return file
168
+ end
169
+
170
+ def _get_local_file(path, scope: $openc3_scope)
171
+ local_file = OpenC3::LocalMode.open_local_file(path, scope: scope)
172
+ return nil unless local_file
173
+ puts "Reading local #{scope}/targets_modified/#{path}"
174
+ file = Tempfile.new('target', binmode: true)
175
+ file.filename = path
176
+ file.write(local_file.read)
177
+ local_file.close
178
+ file.rewind
179
+ return file
180
+ end
181
+
128
182
  def _get_download_url(path, scope: $openc3_scope)
183
+ if OpenC3::LocalMode.local_only_name?(path)
184
+ raise "#{path} belongs to a local only target (OPENC3_LOCAL_ONLY_TARGETS) and has no bucket download URL"
185
+ end
129
186
  targets = "targets_modified" # First try targets_modified
130
187
  response = $api_server.request('get', "/openc3-api/storage/exists/#{scope}/#{targets}/#{path}", query: { bucket: 'OPENC3_CONFIG_BUCKET' }, scope: scope)
131
188
  if response.status != 200
@@ -167,6 +224,20 @@ module OpenC3
167
224
  return file
168
225
  end
169
226
 
227
+ # Retrieve the file selected in open_bucket_dialog
228
+ # input is the hash returned by the frontend: { 'bucket', 'path', 'filename' }
229
+ def _get_bucket_dialog_file(input, scope: $openc3_scope)
230
+ bucket = input['bucket']
231
+ # Path from bucket dialog already includes scope prefix (e.g. DEFAULT/targets/...)
232
+ # _get_storage_file prepends scope, so strip it from the path to avoid doubling
233
+ path = input['path'].sub(/\A#{Regexp.escape(scope)}\//, '')
234
+ file = _get_storage_file(path, bucket: bucket, scope: scope)
235
+ file.filename = input['filename']
236
+ file.bucket = bucket
237
+ file.bucket_path = input['path']
238
+ file
239
+ end
240
+
170
241
  def _get_uri(url)
171
242
  if $openc3_in_cluster
172
243
  case ENV.fetch('OPENC3_CLOUD', nil)
@@ -22,11 +22,15 @@ module OpenC3
22
22
  return _tables_handle_response(response, 'Failed to create binary')
23
23
  end
24
24
 
25
- def table_create_report(filename, definition, table_name: nil, scope: $openc3_scope)
25
+ # save: true (the default) writes the report into the target's storage next to
26
+ # the binary so it can be read back with get_target_file(). Pass save: false to
27
+ # get the contents in the return value without creating a file.
28
+ def table_create_report(filename, definition, table_name: nil, save: true, scope: $openc3_scope)
26
29
  post_data = {}
27
30
  post_data['binary'] = filename
28
31
  post_data['definition'] = definition
29
32
  post_data['table_name'] = table_name if table_name
33
+ post_data['save'] = save
30
34
  response = $api_server.request('post', '/openc3-api/tables/report', json: true, data: post_data, scope: scope)
31
35
  return _tables_handle_response(response, 'Failed to create report')
32
36
  end
@@ -78,7 +78,8 @@ module OpenC3
78
78
  end
79
79
 
80
80
  # target_version can also be the actual hash used in the target_archives folder
81
- def self.setup_targets(target_names, base_dir, target_version: 'current', scope:)
81
+ # @param descriptions [Boolean] Whether to keep packet and item descriptions
82
+ def self.setup_targets(target_names, base_dir, target_version: 'current', scope:, descriptions: true)
82
83
  # Nothing to do if there are no targets
83
84
  return if target_names.nil? or target_names.length == 0
84
85
  if @@instance.nil?
@@ -118,7 +119,7 @@ module OpenC3
118
119
  end
119
120
 
120
121
  # Build System from targets
121
- System.instance(target_names, targets_path)
122
+ System.instance(target_names, targets_path, descriptions: descriptions)
122
123
  end
123
124
  end
124
125
 
@@ -137,14 +138,15 @@ module OpenC3
137
138
  #
138
139
  # @param target_names [Array of target_names]
139
140
  # @param target_config_dir Directory where target config folders are
141
+ # @param descriptions [Boolean] Whether to keep packet and item descriptions
140
142
  # @return [System] The System singleton
141
- def self.instance(target_names = nil, target_config_dir = nil)
143
+ def self.instance(target_names = nil, target_config_dir = nil, descriptions: true)
142
144
  return @@instance if @@instance
143
145
  raise "System.instance parameters are required on first call" unless target_names and target_config_dir
144
146
 
145
147
  @@instance_mutex.synchronize do
146
148
  return @@instance if @@instance
147
- @@instance ||= self.new(target_names, target_config_dir)
149
+ @@instance ||= self.new(target_names, target_config_dir, descriptions: descriptions)
148
150
  @@post_instance_callbacks.each do |callback|
149
151
  callback.call
150
152
  end
@@ -171,10 +173,11 @@ module OpenC3
171
173
  #
172
174
  # @param target_names [Array of target names]
173
175
  # @param target_config_dir Directory where target config folders are
174
- def initialize(target_names, target_config_dir)
176
+ # @param descriptions [Boolean] Whether to keep packet and item descriptions
177
+ def initialize(target_names, target_config_dir, descriptions: true)
175
178
  OpenC3.add_to_search_path(target_config_dir, true) if target_config_dir
176
179
  @targets = {}
177
- @packet_config = PacketConfig.new
180
+ @packet_config = PacketConfig.new(descriptions: descriptions)
178
181
  @commands = Commands.new(@packet_config)
179
182
  @telemetry = Telemetry.new(@packet_config)
180
183
  @limits = Limits.new(@packet_config)
@@ -58,7 +58,7 @@ module OpenC3
58
58
  msg_hash['json_data'] = JSON.generate(json_hash.as_json, allow_nan: true)
59
59
  msg_hash['extra'] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
60
60
  db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
61
- EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash)
61
+ EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash, minid: Topic.stream_safety_minid)
62
62
  end
63
63
 
64
64
  def self.get_cmd_item(target_name, packet_name, param_name, type: :FORMATTED, scope: $openc3_scope)
@@ -35,7 +35,8 @@ module OpenC3
35
35
  buffer: packet.buffer(false) }
36
36
  msg_hash[:extra] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
37
37
  db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
38
- EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash)
38
+ minid = Topic.log_stream_safety_minid(packet.target_name, :CMD, scope: scope)
39
+ EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash, minid: minid)
39
40
  end
40
41
 
41
42
  # @param command [Hash] Command hash structure read to be written to a topic
@@ -84,7 +84,8 @@ module OpenC3
84
84
  Topic.write_topic(topic, { 'target_name' => packet.target_name, 'cmd_name' => packet.packet_name, 'cmd_buffer' => packet.buffer(false) }, '*', 100)
85
85
  elsif target_names.length == 1
86
86
  topic = "{#{scope}__CMD}TARGET__#{target_names[0]}"
87
- Topic.write_topic(topic, { 'target_name' => packet.target_name ? packet.target_name : 'UNKNOWN', 'cmd_name' => 'UNKNOWN', 'cmd_buffer' => packet.buffer(false) }, '*', 100)
87
+ # Name the routed target so the interface identifies against it (and sends it as UNKNOWN if that fails)
88
+ Topic.write_topic(topic, { 'target_name' => target_names[0], 'cmd_name' => 'UNKNOWN', 'cmd_buffer' => packet.buffer(false) }, '*', 100)
88
89
  else
89
90
  raise "No route for command: #{packet.target_name ? packet.target_name : 'UNKNOWN'} #{packet.packet_name ? packet.packet_name : 'UNKNOWN'}"
90
91
  end
@@ -45,7 +45,7 @@ module OpenC3
45
45
  }
46
46
  msg_hash[:extra] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
47
47
  db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
48
- Topic.write_topic("#{scope}__DECOM__{#{packet.target_name}}__#{packet.packet_name}", msg_hash, id, db_shard: db_shard)
48
+ Topic.write_topic("#{scope}__DECOM__{#{packet.target_name}}__#{packet.packet_name}", msg_hash, id, minid: Topic.stream_safety_minid(id), db_shard: db_shard)
49
49
 
50
50
  unless packet.stored
51
51
  # Also update the current value table with the latest decommutated data
@@ -33,10 +33,11 @@ module OpenC3
33
33
  msg_hash[:extra] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
34
34
  topic = "#{scope}__TELEMETRY__{#{packet.target_name}}__#{packet.packet_name}"
35
35
  db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
36
+ minid = Topic.log_stream_safety_minid(packet.target_name, :TLM, scope: scope)
36
37
  if queued
37
- EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash)
38
+ EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash, minid: minid)
38
39
  else
39
- Topic.write_topic(topic, msg_hash, db_shard: db_shard)
40
+ Topic.write_topic(topic, msg_hash, minid: minid, db_shard: db_shard)
40
41
  end
41
42
  end
42
43
  end
@@ -16,9 +16,48 @@
16
16
  # if purchased from OpenC3, Inc.
17
17
 
18
18
  require 'openc3/utilities/store'
19
+ require 'openc3/utilities/logger'
19
20
 
20
21
  module OpenC3
21
22
  class Topic
23
+ # Stream trimming contract for the high rate target streams
24
+ # (TELEMETRY__, COMMAND__, DECOM__, DECOMCMD__):
25
+ #
26
+ # The streams are primarily trimmed by their consumers once the data is safely
27
+ # persisted elsewhere:
28
+ # * TELEMETRY__ / COMMAND__ are trimmed by the log microservice (LogWriter) once a
29
+ # log file has been moved to the bucket plus LogWriter::CLEANUP_DELAY.
30
+ # * DECOM__ / DECOMCMD__ are trimmed by the TSDB microservice which keeps
31
+ # TsdbMicroservice::TRIM_KEEP_MS of data.
32
+ #
33
+ # If a trimmer is absent (logging disabled, no TSDB) or falls far behind, the streams
34
+ # would grow without bound. As a safety net every write to these streams also passes
35
+ # an approximate XADD MINID so no entry older than the safety max age is retained.
36
+ # The max age is OPENC3_STREAM_MAX_AGE_SECONDS (default 600s, 0 or empty disables) and is
37
+ # never less than STREAM_MAX_AGE_MIN_SECONDS. For TELEMETRY__ / COMMAND__ the max age is
38
+ # never less than two log cycles plus the cleanup delay, because historical streaming reads
39
+ # data not yet in the bucket from the stream.
40
+ #
41
+ # When the minid is calculated from now (auto generated ids) it uses this host's clock while
42
+ # Redis generates the ids from its own clock. A writer clock ahead of Redis by more than the
43
+ # max age would trim the entire stream (including the entry being added) so hosts must be
44
+ # time synchronized (NTP).
45
+ STREAM_MAX_AGE_DEFAULT_SECONDS = 600
46
+ # Twice TsdbMicroservice::TRIM_KEEP_MS. Also protects against small clock offsets.
47
+ STREAM_MAX_AGE_MIN_SECONDS = 120
48
+ # LogWriter::CLEANUP_DELAY is defined from this
49
+ LOG_CLEANUP_DELAY_SECONDS = 60
50
+ # TargetModel default log cycle time
51
+ DEFAULT_LOG_CYCLE_TIME_SECONDS = 600
52
+ # How long to wait before retrying a failed log cycle time lookup
53
+ LOG_CYCLE_LOOKUP_RETRY_SECONDS = 60
54
+ # These are calculated on the write path of every packet so they are cached per process.
55
+ # The environment doesn't change while a microservice runs and the log cycle time only
56
+ # changes when the plugin (and therefore the microservices) are reinstalled.
57
+ @@stream_max_age_ms = nil
58
+ @@log_stream_max_age_ms = {}
59
+ @@log_cycle_lookup_retry_at = {}
60
+
22
61
  # Delegate all unknown class methods to EphemeralStore db_shard 0 (system-level topics)
23
62
  def self.method_missing(message, *args, **kwargs, &block)
24
63
  EphemeralStore.public_send(message, *args, **kwargs, &block)
@@ -37,8 +76,121 @@ module OpenC3
37
76
  # DB_Shard-aware topic methods for target-specific streams.
38
77
  # These explicitly route to the correct EphemeralStore db_shard.
39
78
 
40
- def self.write_topic(topic, msg_hash, id = '*', maxlen = nil, approximate = 'true', db_shard: 0)
41
- EphemeralStore.instance(db_shard: db_shard).write_topic(topic, msg_hash, id, maxlen, approximate)
79
+ def self.write_topic(topic, msg_hash, id = '*', maxlen = nil, approximate = 'true', minid: nil, db_shard: 0)
80
+ EphemeralStore.instance(db_shard: db_shard).write_topic(topic, msg_hash, id, maxlen, approximate, minid: minid)
81
+ end
82
+
83
+ # @return [Float] Configured safety max age in seconds (<= 0 means disabled)
84
+ def self.stream_max_age_seconds
85
+ value = ENV.fetch('OPENC3_STREAM_MAX_AGE_SECONDS', nil)
86
+ return STREAM_MAX_AGE_DEFAULT_SECONDS if value.nil?
87
+ return 0.0 if value.strip.empty?
88
+ # Strictly parse and reject nan / inf, which would raise when converted to
89
+ # milliseconds on every write. A typo keeps the safety cap at the default
90
+ # rather than disabling it.
91
+ seconds = Float(value, exception: false)
92
+ return seconds if seconds&.finite?
93
+ Logger.warn("OPENC3_STREAM_MAX_AGE_SECONDS of '#{value}' is invalid, using #{STREAM_MAX_AGE_DEFAULT_SECONDS}")
94
+ return STREAM_MAX_AGE_DEFAULT_SECONDS
95
+ end
96
+
97
+ # @return [Integer] Cached safety max age in milliseconds (0 means disabled)
98
+ def self.stream_max_age_ms
99
+ @@stream_max_age_ms ||= begin
100
+ seconds = stream_max_age_seconds
101
+ if seconds <= 0
102
+ 0
103
+ elsif seconds < STREAM_MAX_AGE_MIN_SECONDS
104
+ Logger.warn("OPENC3_STREAM_MAX_AGE_SECONDS of #{seconds} is below the minimum, using #{STREAM_MAX_AGE_MIN_SECONDS}")
105
+ STREAM_MAX_AGE_MIN_SECONDS * 1000
106
+ else
107
+ (seconds * 1000).to_i
108
+ end
109
+ end
110
+ end
111
+
112
+ # Calculates the MINID to pass to XADD to cap the age of entries in a target stream.
113
+ # The cap is anchored to the id being written (or now for auto generated ids) so an
114
+ # explicitly id'd entry (e.g. decom reusing the raw packet id) is never trimmed by its own add.
115
+ #
116
+ # @param id [String] The id being written ('*' or nil for auto generation)
117
+ # @param max_age_ms [Integer] Max age to retain (<= 0 means disabled)
118
+ # @return [String|nil] The minid or nil if the safety cap is disabled
119
+ def self.stream_safety_minid(id = '*', max_age_ms: stream_max_age_ms)
120
+ return nil if max_age_ms <= 0
121
+ if id.nil? or id == '*'
122
+ base_ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
123
+ else
124
+ base_ms = id.to_s.to_i # Stops at the '-' sequence separator
125
+ end
126
+ minid_ms = base_ms - max_age_ms
127
+ return nil if minid_ms <= 0
128
+ return minid_ms.to_s
129
+ end
130
+
131
+ # Calculates the MINID for a TELEMETRY__ / COMMAND__ stream write which also never
132
+ # trims younger than log_stream_min_age_seconds. The resulting max age is cached per stream.
133
+ #
134
+ # @param target_name [String] Target name
135
+ # @param cmd_or_tlm [Symbol] :CMD or :TLM
136
+ # @return [String|nil] The minid or nil if the safety cap is disabled
137
+ def self.log_stream_safety_minid(target_name, cmd_or_tlm, scope:)
138
+ target_cache = ((@@log_stream_max_age_ms[scope] ||= {})[target_name] ||= {})
139
+ max_age_ms = target_cache[cmd_or_tlm]
140
+ unless max_age_ms
141
+ max_age_ms = log_stream_max_age_ms(target_name, cmd_or_tlm, scope: scope)
142
+ return nil unless max_age_ms # Log cycle time unknown so don't cap yet
143
+ target_cache[cmd_or_tlm] = max_age_ms
144
+ end
145
+ return stream_safety_minid(max_age_ms: max_age_ms)
146
+ end
147
+
148
+ # @param target_name [String] Target name
149
+ # @param cmd_or_tlm [Symbol] :CMD or :TLM
150
+ # @return [Integer|nil] Max age in milliseconds for a TELEMETRY__ / COMMAND__ stream (0 means
151
+ # disabled) or nil if the log cycle time couldn't be determined. Guessing the log cycle time
152
+ # could trim data historical streaming still needs, so the stream isn't capped until a
153
+ # lookup (retried every LOG_CYCLE_LOOKUP_RETRY_SECONDS) succeeds.
154
+ def self.log_stream_max_age_ms(target_name, cmd_or_tlm, scope:)
155
+ max_age_ms = stream_max_age_ms
156
+ return max_age_ms if max_age_ms <= 0
157
+ key = [scope, target_name, cmd_or_tlm]
158
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
159
+ retry_at = @@log_cycle_lookup_retry_at[key]
160
+ return nil if retry_at and now < retry_at
161
+ begin
162
+ min_age_ms = log_stream_min_age_seconds(target_name, cmd_or_tlm, scope: scope) * 1000
163
+ rescue => e
164
+ Logger.warn("Unable to determine log cycle time for #{target_name} #{cmd_or_tlm}, stream age not capped: #{e.message}")
165
+ @@log_cycle_lookup_retry_at[key] = now + LOG_CYCLE_LOOKUP_RETRY_SECONDS
166
+ return nil
167
+ end
168
+ @@log_cycle_lookup_retry_at.delete(key)
169
+ return [max_age_ms, min_age_ms].max
170
+ end
171
+
172
+ # Minimum age to retain in a TELEMETRY__ / COMMAND__ stream so the log microservice
173
+ # and historical streaming always have the data not yet moved to the bucket.
174
+ #
175
+ # @param target_name [String] Target name
176
+ # @param cmd_or_tlm [Symbol] :CMD or :TLM
177
+ # @return [Integer] Minimum age in seconds
178
+ # @raise If the TargetModel lookup fails
179
+ def self.log_stream_min_age_seconds(target_name, cmd_or_tlm, scope:)
180
+ require 'openc3/models/target_model'
181
+ cycle_time = DEFAULT_LOG_CYCLE_TIME_SECONDS
182
+ model = TargetModel.get(name: target_name, scope: scope)
183
+ if model
184
+ value = model[cmd_or_tlm == :CMD ? 'cmd_log_cycle_time' : 'tlm_log_cycle_time'].to_i
185
+ cycle_time = value if value > 0
186
+ end
187
+ return 2 * (cycle_time + LOG_CLEANUP_DELAY_SECONDS)
188
+ end
189
+
190
+ def self.clear_stream_max_age_cache
191
+ @@stream_max_age_ms = nil
192
+ @@log_stream_max_age_ms = {}
193
+ @@log_cycle_lookup_retry_at = {}
42
194
  end
43
195
 
44
196
  def self.read_topics(topics, offsets = nil, timeout_ms = 1000, count = nil, db_shard: 0, &block)
@@ -11,6 +11,8 @@
11
11
  # This file may also be used under the terms of a commercial license
12
12
  # if purchased from OpenC3, Inc.
13
13
 
14
+ require 'open3'
15
+
14
16
  module OpenC3
15
17
  class CliGenerator
16
18
  GENERATORS = %w(plugin target microservice widget conversion processor limits_response tool tool_vue tool_angular tool_react tool_svelte command_validator)
@@ -392,9 +394,26 @@ module OpenC3
392
394
  end
393
395
  FileUtils.mkdir(plugin_name)
394
396
  Dir.chdir(plugin_name) # Change to the plugin path to make copying easier
397
+ # Every installed plugin's lib/ goes on sys.path, so python code shared
398
+ # across the plugin lives in a package named after the plugin rather than
399
+ # directly in lib/, where a same-named module in another plugin collides.
400
+ package_name = plugin_name.gsub(/[^a-z0-9]+/, '_')
395
401
 
396
402
  process_template("#{TEMPLATES_DIR}/plugin", binding) do |filename|
397
403
  filename.sub!("plugin.gemspec", "#{plugin_name}.gemspec")
404
+ filename.sub!("lib/PACKAGE", "lib/#{package_name}")
405
+ # Only python plugins get a pyproject.toml. Shipping one in every plugin
406
+ # would make install_phase2 take the python dependency path (and build a
407
+ # per-plugin venv) for plugins that have no python code at all.
408
+ #
409
+ # lib/ comes with it because pyproject.toml sets ty's extra-paths to
410
+ # lib/, and ty exits non-zero rather than warning when a configured path
411
+ # does not exist. The package's __init__.py does reach the gem, which is
412
+ # harmless: the gem's pyproject.toml already sets needs_dependencies.
413
+ if @@language != 'py'
414
+ next true if filename == 'pyproject.toml'
415
+ next true if filename == 'lib' or filename.start_with?('lib/')
416
+ end
398
417
  false
399
418
  end
400
419
 
@@ -409,10 +428,28 @@ module OpenC3
409
428
  end
410
429
  end
411
430
 
431
+ write_python_version if @@language == 'py'
432
+
412
433
  puts "Plugin #{plugin_name} successfully generated!"
413
434
  return plugin_name
414
435
  end
415
436
 
437
+ # Pin the plugin's local uv environment to the Python minor version COSMOS
438
+ # runs, read from the interpreter itself rather than hardcoded in the
439
+ # template: it differs between images (Debian vs UBI) and moves with the base
440
+ # image. `cli generate` runs in the cmd-tlm-api image, which sets
441
+ # OPENC3_PYTHON_BIN. Outside an image (e.g. the gem installed on a dev
442
+ # machine) there is no COSMOS Python to match, so no file is written.
443
+ def self.write_python_version
444
+ python_bin = ENV.fetch('OPENC3_PYTHON_BIN', nil)
445
+ return if python_bin.nil? || !File.executable?(python_bin)
446
+ version, status = Open3.capture2(python_bin, '-c', 'import sys; print("%d.%d" % sys.version_info[:2])')
447
+ return unless status.success? && version.strip =~ /\A\d+\.\d+\z/
448
+ File.write('.python-version', "#{version.strip}\n")
449
+ rescue SystemCallError
450
+ nil
451
+ end
452
+
416
453
  def self.generate_target(args)
417
454
  validate_language_inheriting_args!('target', args,
418
455
  example: ['cli generate target EXAMPLE --ruby', 'Creates: targets/EXAMPLE/'],
@@ -447,18 +484,33 @@ module OpenC3
447
484
  end
448
485
  gemspec_filename = Dir['*.gemspec'][0]
449
486
  gemspec = File.read(gemspec_filename)
450
- gemspec.gsub!(/s\.files = Dir\.glob.*\n/) do |match|
451
- <<RUBY
452
- # Prefer pyproject.toml over requirements.txt
453
- python_dep_file = if File.exist?('pyproject.toml')
454
- 'pyproject.toml'
455
- else
456
- 'requirements.txt'
457
- end
458
- s.files = Dir.glob("{targets,lib,public,tools,microservices}/**/*") + %w(Rakefile README.md LICENSE.md plugin.txt) + [python_dep_file]
459
- RUBY
487
+ # Older COSMOS versions wrote a singular python_dep_file block that never
488
+ # ships uv.lock, and a second python target nested another copy of it.
489
+ # Strip every copy plus its use on the s.files line so the rewrite below
490
+ # replaces it. A hand-edited block that no longer matches leaves some
491
+ # python_dep_file reference behind, so the gemspec is kept as it was.
492
+ legacy_block = /^ *# Prefer pyproject\.toml over requirements\.txt\n *python_dep_file = if File\.exist\?\('pyproject\.toml'\)\n *'pyproject\.toml'\n *else\n *'requirements\.txt'\n *end\n/
493
+ upgraded = gemspec.gsub(legacy_block, '').sub(' + [python_dep_file]', '')
494
+ gemspec = upgraded unless upgraded.include?('python_dep_file')
495
+ # Skip when a previous python target already rewrote s.files: the line it
496
+ # wrote matches the pattern below too, so rewriting again would nest a
497
+ # second dependency block. python_dep_file also covers a legacy block
498
+ # the upgrade above left alone. Also skip a gemspec from the
499
+ # python plugin template, which already ships pyproject.toml and uv.lock.
500
+ unless gemspec.include?('python_dep_file') || gemspec.include?('pyproject.toml')
501
+ gemspec.gsub!(/^ *s\.files = Dir\.glob.*\n/) do
502
+ <<~RUBY.gsub(/^/, ' ')
503
+ # Prefer pyproject.toml (plus uv.lock if committed) over requirements.txt
504
+ python_dep_files = if File.exist?('pyproject.toml')
505
+ ['pyproject.toml'] + Dir.glob('uv.lock')
506
+ else
507
+ ['requirements.txt']
508
+ end
509
+ s.files = Dir.glob("{targets,lib,public,tools,microservices}/**/*") + %w(Rakefile README.md LICENSE.md plugin.txt) + python_dep_files
510
+ RUBY
511
+ end
512
+ File.write(gemspec_filename, gemspec)
460
513
  end
461
- File.write(gemspec_filename, gemspec)
462
514
 
463
515
  target_txt_filename = "targets/#{target_name}/target.txt"
464
516
  target_txt = File.read(target_txt_filename)
@@ -626,8 +678,8 @@ RUBY
626
678
  arguments: common_args,
627
679
  notes: common_note,
628
680
  example: [
629
- "cli generate #{args[0]} 'Data Viewer'",
630
- "Creates: tools/dataviewer/ (#{framework}-based)",
681
+ "cli generate #{args[0]} 'Mission Planner'",
682
+ "Creates: tools/missionplanner/ (#{framework}-based)",
631
683
  ],
632
684
  in_plugin_extra: 'For other tool types, see: cli generate tool --help',
633
685
  docs: docs_url,
@@ -650,8 +702,8 @@ RUBY
650
702
  ],
651
703
  },
652
704
  example: [
653
- "cli generate tool 'Data Viewer'",
654
- 'Creates: tools/dataviewer/',
705
+ "cli generate tool 'Mission Planner'",
706
+ 'Creates: tools/missionplanner/',
655
707
  ],
656
708
  docs: docs_url,
657
709
  exit_code: (args[1].nil? ? 1 : 0),