openc3 7.0.0.pre.rc2 → 7.0.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +13 -4
  3. data/bin/pipinstall +6 -7
  4. data/bin/pipuninstall +3 -5
  5. data/data/config/interface_modifiers.yaml +1 -1
  6. data/data/config/item_modifiers.yaml +18 -6
  7. data/data/config/telemetry.yaml +1 -1
  8. data/data/config/widgets.yaml +10 -0
  9. data/lib/openc3/accessors/json_accessor.rb +1 -1
  10. data/lib/openc3/api/cmd_api.rb +2 -0
  11. data/lib/openc3/api/settings_api.rb +2 -0
  12. data/lib/openc3/api/tlm_api.rb +3 -3
  13. data/lib/openc3/config/config_parser.rb +4 -4
  14. data/lib/openc3/conversions/conversion.rb +3 -3
  15. data/lib/openc3/core_ext/faraday.rb +4 -0
  16. data/lib/openc3/logs/log_writer.rb +24 -6
  17. data/lib/openc3/logs/packet_log_writer.rb +1 -4
  18. data/lib/openc3/logs/stream_log_pair.rb +11 -4
  19. data/lib/openc3/logs/text_log_writer.rb +1 -4
  20. data/lib/openc3/microservices/interface_microservice.rb +8 -2
  21. data/lib/openc3/microservices/log_microservice.rb +7 -2
  22. data/lib/openc3/microservices/microservice.rb +10 -4
  23. data/lib/openc3/microservices/queue_microservice.rb +9 -2
  24. data/lib/openc3/microservices/scope_cleanup_microservice.rb +116 -1
  25. data/lib/openc3/microservices/text_log_microservice.rb +4 -1
  26. data/lib/openc3/migrations/20241208080000_no_critical_cmd.rb +1 -1
  27. data/lib/openc3/migrations/20250402000000_periodic_only_default.rb +1 -1
  28. data/lib/openc3/migrations/20260203000000_remove_store_id.rb +28 -0
  29. data/lib/openc3/migrations/20260204000000_remove_decom_reducer.rb +29 -1
  30. data/lib/openc3/models/activity_model.rb +41 -9
  31. data/lib/openc3/models/auth_model.rb +54 -19
  32. data/lib/openc3/models/cvt_model.rb +2 -265
  33. data/lib/openc3/models/model.rb +16 -0
  34. data/lib/openc3/models/plugin_model.rb +18 -12
  35. data/lib/openc3/models/plugin_store_model.rb +1 -1
  36. data/lib/openc3/models/python_package_model.rb +2 -2
  37. data/lib/openc3/models/queue_model.rb +5 -3
  38. data/lib/openc3/models/script_engine_model.rb +1 -1
  39. data/lib/openc3/models/target_model.rb +75 -42
  40. data/lib/openc3/models/tool_config_model.rb +12 -0
  41. data/lib/openc3/models/tool_model.rb +18 -5
  42. data/lib/openc3/models/trigger_model.rb +1 -1
  43. data/lib/openc3/models/widget_model.rb +2 -9
  44. data/lib/openc3/operators/operator.rb +9 -7
  45. data/lib/openc3/packets/json_packet.rb +2 -0
  46. data/lib/openc3/packets/packet.rb +1 -0
  47. data/lib/openc3/packets/packet_config.rb +28 -12
  48. data/lib/openc3/script/calendar.rb +8 -0
  49. data/lib/openc3/script/script.rb +19 -0
  50. data/lib/openc3/script/storage.rb +6 -6
  51. data/lib/openc3/script/web_socket_api.rb +1 -1
  52. data/lib/openc3/system/system.rb +6 -6
  53. data/lib/openc3/tools/cmd_tlm_server/interface_thread.rb +0 -2
  54. data/lib/openc3/top_level.rb +15 -63
  55. data/lib/openc3/topics/command_topic.rb +1 -0
  56. data/lib/openc3/topics/limits_event_topic.rb +1 -1
  57. data/lib/openc3/utilities/authentication.rb +46 -7
  58. data/lib/openc3/utilities/authorization.rb +8 -1
  59. data/lib/openc3/utilities/aws_bucket.rb +2 -3
  60. data/lib/openc3/utilities/bucket_utilities.rb +3 -1
  61. data/lib/openc3/utilities/cli_generator.rb +7 -0
  62. data/lib/openc3/utilities/cmd_log.rb +1 -1
  63. data/lib/openc3/utilities/local_mode.rb +3 -0
  64. data/lib/openc3/utilities/process_manager.rb +1 -1
  65. data/lib/openc3/utilities/python_proxy.rb +11 -4
  66. data/lib/openc3/utilities/questdb_client.rb +764 -2
  67. data/lib/openc3/utilities/running_script.rb +25 -7
  68. data/lib/openc3/utilities/script.rb +452 -0
  69. data/lib/openc3/utilities/secrets.rb +1 -1
  70. data/lib/openc3/version.rb +5 -5
  71. data/templates/conversion/conversion.py +0 -8
  72. data/templates/conversion/conversion.rb +0 -11
  73. data/templates/tool_angular/package.json +2 -2
  74. data/templates/tool_react/package.json +1 -1
  75. data/templates/tool_svelte/package.json +1 -1
  76. data/templates/tool_vue/package.json +3 -3
  77. data/templates/widget/package.json +2 -2
  78. metadata +19 -19
  79. data/lib/openc3/migrations/20251022000000_remove_unique_id.rb +0 -23
  80. data/lib/openc3/migrations/20251213120000_reinstall_plugins.rb +0 -45
@@ -15,20 +15,15 @@
15
15
  # This file may also be used under the terms of a commercial license
16
16
  # if purchased from OpenC3, Inc.
17
17
 
18
- require 'pg'
19
- require 'set'
20
- require 'thread'
21
18
  require 'openc3/utilities/store'
22
19
  require 'openc3/utilities/store_queued'
23
20
  require 'openc3/utilities/questdb_client'
24
- require 'openc3/models/target_model'
21
+ # require 'openc3/models/target_model' # Circular require
25
22
 
26
23
  module OpenC3
27
24
  class CvtModel
28
25
  @@packet_cache = {}
29
26
  @@override_cache = {}
30
- @@conn = nil
31
- @@conn_mutex = Mutex.new
32
27
 
33
28
  VALUE_TYPES = [:RAW, :CONVERTED, :FORMATTED]
34
29
  def self.build_json_from_packet(packet)
@@ -130,265 +125,7 @@ module OpenC3
130
125
  end
131
126
 
132
127
  def self.tsdb_lookup(items, start_time:, end_time: nil, scope: $openc3_scope)
133
- tables = {}
134
- names = []
135
- nil_count = 0
136
- # Cache packet definitions to avoid repeated lookups
137
- packet_cache = {}
138
- # Map column names to item type info for decoding
139
- item_types = {}
140
- # Track calculated timestamp items: { position => { source:, format:, table_index: } }
141
- calculated_items = {}
142
- # Track which timestamp columns we need per table
143
- needed_timestamps = {} # { table_index => Set of column names }
144
- current_position = 0
145
-
146
- # Stored timestamp items that need conversion from timestamp_ns to float seconds
147
- stored_timestamp_items = Set.new(['PACKET_TIMESECONDS', 'RECEIVED_TIMESECONDS'])
148
- # Track stored timestamp items: { position => { column:, table_index: } }
149
- stored_timestamp_positions = {}
150
-
151
- items.each do |item|
152
- target_name, packet_name, orig_item_name, value_type, limits = item
153
- # They will all be nil when item is a nil value
154
- # A nil value indicates a value that does not exist as returned by get_tlm_available
155
- if orig_item_name.nil?
156
- # We know PACKET_TIMESECONDS always exists so we can use it to fill in the nil value
157
- names << "PACKET_TIMESECONDS as __nil#{nil_count}"
158
- nil_count += 1
159
- current_position += 1
160
- next
161
- end
162
- table_name = QuestDBClient.sanitize_table_name(target_name, packet_name, scope: scope)
163
- tables[table_name] = 1
164
- index = tables.find_index {|k,v| k == table_name }
165
-
166
- # Check if this is a stored timestamp item (PACKET_TIMESECONDS or RECEIVED_TIMESECONDS)
167
- # These are stored as timestamp_ns columns and need conversion to float seconds on read
168
- if stored_timestamp_items.include?(orig_item_name)
169
- col_name = "T#{index}.#{orig_item_name}"
170
- names << "\"#{col_name}\""
171
- stored_timestamp_positions[current_position] = { column: col_name, table_index: index }
172
- current_position += 1
173
- next
174
- end
175
-
176
- # Check if this is a calculated timestamp item (PACKET_TIMEFORMATTED or RECEIVED_TIMEFORMATTED)
177
- if QuestDBClient::TIMESTAMP_ITEMS.key?(orig_item_name)
178
- ts_info = QuestDBClient::TIMESTAMP_ITEMS[orig_item_name]
179
- calculated_items[current_position] = {
180
- source: ts_info[:source],
181
- format: ts_info[:format],
182
- table_index: index
183
- }
184
- # Track that we need this timestamp column for this table
185
- needed_timestamps[index] ||= Set.new
186
- needed_timestamps[index] << ts_info[:source]
187
- current_position += 1
188
- next
189
- end
190
-
191
- safe_item_name = QuestDBClient.sanitize_column_name(orig_item_name)
192
-
193
- # Look up item type info from packet definition
194
- cache_key = [target_name, packet_name]
195
- unless packet_cache.key?(cache_key)
196
- begin
197
- packet_cache[cache_key] = TargetModel.packet(target_name, packet_name, scope: scope)
198
- rescue RuntimeError
199
- packet_cache[cache_key] = nil
200
- end
201
- end
202
-
203
- packet_def = packet_cache[cache_key]
204
- item_def = nil
205
- if packet_def
206
- packet_def['items']&.each do |pkt_item|
207
- if pkt_item['name'] == orig_item_name
208
- item_def = pkt_item
209
- break
210
- end
211
- end
212
- end
213
-
214
- case value_type
215
- when 'FORMATTED', 'WITH_UNITS'
216
- col_name = "T#{index}.#{safe_item_name}__F"
217
- names << "\"#{col_name}\""
218
- # Formatted values are always strings, no special decoding needed
219
- item_types[col_name] = { 'data_type' => 'STRING', 'array_size' => nil }
220
- when 'CONVERTED'
221
- col_name = "T#{index}.#{safe_item_name}__C"
222
- names << "\"#{col_name}\""
223
- # Converted values may have different types based on read_conversion
224
- if item_def
225
- rc = item_def['read_conversion']
226
- if rc && rc['converted_type']
227
- item_types[col_name] = { 'data_type' => rc['converted_type'], 'array_size' => item_def['array_size'] }
228
- elsif item_def['states']
229
- # State values are strings
230
- item_types[col_name] = { 'data_type' => 'STRING', 'array_size' => nil }
231
- else
232
- item_types[col_name] = { 'data_type' => item_def['data_type'], 'array_size' => item_def['array_size'] }
233
- end
234
- else
235
- item_types[col_name] = { 'data_type' => nil, 'array_size' => nil }
236
- end
237
- else
238
- col_name = "T#{index}.#{safe_item_name}"
239
- names << "\"#{col_name}\""
240
- if item_def
241
- item_types[col_name] = { 'data_type' => item_def['data_type'], 'array_size' => item_def['array_size'] }
242
- else
243
- item_types[col_name] = { 'data_type' => nil, 'array_size' => nil }
244
- end
245
- end
246
- current_position += 1
247
- if limits
248
- names << "\"T#{index}.#{safe_item_name}__L\""
249
- end
250
- end
251
-
252
- # Add needed timestamp columns to the SELECT
253
- # Track which column alias maps to which timestamp source for result processing
254
- # Note: We use underscores in the alias name to avoid needing quotes, which QuestDB includes in returned field names
255
- timestamp_columns = {} # { "T0___ts_timestamp" => { table_index: 0, source: 'timestamp' } }
256
- needed_timestamps.each do |table_index, ts_columns|
257
- ts_columns.each do |ts_col|
258
- alias_name = "T#{table_index}___ts_#{ts_col}"
259
- names << "T#{table_index}.#{ts_col} as #{alias_name}"
260
- timestamp_columns[alias_name] = { table_index: table_index, source: ts_col }
261
- end
262
- end
263
-
264
- # Build the SQL query
265
- query = "SELECT #{names.join(", ")} FROM "
266
- tables.each_with_index do |(table_name, _), index|
267
- if index == 0
268
- query += "#{table_name} as T#{index} "
269
- else
270
- query += "ASOF JOIN #{table_name} as T#{index} "
271
- end
272
- end
273
- if start_time && !end_time
274
- query += "WHERE T0.PACKET_TIMESECONDS < '#{start_time}' LIMIT -1"
275
- elsif start_time && end_time
276
- query += "WHERE T0.PACKET_TIMESECONDS >= '#{start_time}' AND T0.PACKET_TIMESECONDS < '#{end_time}'"
277
- end
278
-
279
- retry_count = 0
280
- begin
281
- @@conn_mutex.synchronize do
282
- @@conn ||= PG::Connection.new(host: ENV['OPENC3_TSDB_HOSTNAME'],
283
- port: ENV['OPENC3_TSDB_QUERY_PORT'],
284
- user: ENV['OPENC3_TSDB_USERNAME'],
285
- password: ENV['OPENC3_TSDB_PASSWORD'],
286
- dbname: 'qdb')
287
- # Default connection is all strings but we want to map to the correct types
288
- if @@conn.type_map_for_results.is_a? PG::TypeMapAllStrings
289
- # TODO: This doesn't seem to be round tripping UINT64 correctly
290
- # Try playback with P_2.2,2 and P(:6;): from the DEMO
291
- @@conn.type_map_for_results = PG::BasicTypeMapForResults.new @@conn
292
- end
293
-
294
- result = @@conn.exec(query)
295
- if result.nil? or result.ntuples == 0
296
- return {}
297
- else
298
- data = []
299
- # Build up a results set that is an array of arrays
300
- # Each nested array is a set of 2 items: [value, limits state]
301
- # If the item does not have limits the limits state is nil
302
- result.each_with_index do |tuples, row_num|
303
- data[row_num] ||= []
304
- row_index = 0
305
- # Store timestamp values for this row: { "T0.PACKET_TIMESECONDS" => Time, ... }
306
- row_timestamps = {}
307
- tuples.each do |tuple|
308
- col_name = tuple[0]
309
- col_value = tuple[1]
310
- if col_name.include?("__L")
311
- data[row_num][row_index - 1][1] = col_value
312
- elsif col_name =~ /^__nil/
313
- data[row_num][row_index] = [nil, nil]
314
- row_index += 1
315
- elsif col_name =~ /^T(\d+)___ts_(.+)$/
316
- # This is a timestamp column for calculated items (TIMEFORMATTED)
317
- table_idx = $1.to_i
318
- ts_source = $2
319
- row_timestamps["T#{table_idx}.#{ts_source}"] = col_value
320
- elsif col_name.end_with?('.PACKET_TIMESECONDS', '.RECEIVED_TIMESECONDS') || col_name == 'PACKET_TIMESECONDS' || col_name == 'RECEIVED_TIMESECONDS'
321
- # Stored timestamp column - convert from datetime to float seconds
322
- ts_utc = QuestDBClient.pg_timestamp_to_utc(col_value)
323
- seconds_value = QuestDBClient.format_timestamp(ts_utc, :seconds)
324
- data[row_num][row_index] = [seconds_value, nil]
325
- row_index += 1
326
- # Also store for calculated items (TIMEFORMATTED) that may need this
327
- # Normalize key to T{index}.{col} format for consistency
328
- if col_name.include?('.')
329
- row_timestamps[col_name] = col_value
330
- else
331
- row_timestamps["T0.#{col_name}"] = col_value
332
- end
333
- else
334
- # Decode value using item type info
335
- # QuestDB may return column names without table alias prefix
336
- # Try both the raw column name and prefixed versions
337
- type_info = item_types[col_name]
338
- unless type_info
339
- tables.length.times do |i|
340
- prefixed_name = "T#{i}.#{col_name}"
341
- type_info = item_types[prefixed_name]
342
- break if type_info
343
- end
344
- type_info ||= {}
345
- end
346
- decoded_value = QuestDBClient.decode_value(
347
- col_value,
348
- data_type: type_info['data_type'],
349
- array_size: type_info['array_size']
350
- )
351
- data[row_num][row_index] = [decoded_value, nil]
352
- row_index += 1
353
- end
354
- end
355
-
356
- # Insert calculated timestamp items at their positions
357
- # Insert in ascending order so positions remain valid after each insert
358
- calculated_items.keys.sort.each do |position|
359
- calc_info = calculated_items[position]
360
- ts_key = "T#{calc_info[:table_index]}.#{calc_info[:source]}"
361
- ts_value = row_timestamps[ts_key]
362
- ts_utc = QuestDBClient.pg_timestamp_to_utc(ts_value)
363
- calculated_value = QuestDBClient.format_timestamp(ts_utc, calc_info[:format])
364
- data[row_num].insert(position, [calculated_value, nil])
365
- end
366
- end
367
- # If we only have one row then we return a single array
368
- if result.ntuples == 1
369
- data = data[0]
370
- end
371
- return data
372
- end
373
- end
374
- rescue IOError, PG::Error => e
375
- # Retry the query because various errors can occur that are recoverable
376
- retry_count += 1
377
- if retry_count > 4
378
- # After the 5th retry just raise the error
379
- raise "Error querying TSDB: #{e.message}"
380
- end
381
- Logger.warn("TSDB: Retrying due to error: #{e.message}")
382
- Logger.warn("TSDB: Last query: #{query}") # Log the last query for debugging
383
- @@conn_mutex.synchronize do
384
- if @@conn and !@@conn.finished?
385
- @@conn.finish()
386
- end
387
- @@conn = nil # Force the new connection
388
- end
389
- sleep 0.1
390
- retry
391
- end
128
+ QuestDBClient.tsdb_lookup(items, start_time: start_time, end_time: end_time, scope: scope)
392
129
  end
393
130
 
394
131
  # Return all item values and limit state from the CVT
@@ -194,6 +194,22 @@ module OpenC3
194
194
  'scope' => @scope }
195
195
  end
196
196
 
197
+ # Compare this model's as_json with a previous Hash and return an array
198
+ # of human-readable change descriptions (e.g. "key: old -> new").
199
+ # Skips the updated_at field since it always changes.
200
+ # @param existing [Hash] the previous model state (from .get)
201
+ # @return [Array<String>] list of changed fields
202
+ def diff(existing)
203
+ changes = []
204
+ new_json = as_json
205
+ existing.each do |key, old_value|
206
+ next if key == 'updated_at'
207
+ new_value = new_json[key]
208
+ changes << "#{key}: #{old_value} -> #{new_value}" if old_value != new_value
209
+ end
210
+ changes
211
+ end
212
+
197
213
  def check_disable_erb(filename)
198
214
  erb_disabled = false
199
215
  if @disable_erb
@@ -53,7 +53,8 @@ module OpenC3
53
53
  attr_accessor :plugin_txt_lines
54
54
  attr_accessor :minimum_cosmos_version
55
55
  attr_accessor :needs_dependencies
56
- attr_accessor :store_id
56
+ attr_accessor :store_plugin_id
57
+ attr_accessor :store_version_id
57
58
  attr_accessor :title
58
59
  attr_accessor :description
59
60
  attr_accessor :licenses
@@ -78,7 +79,7 @@ module OpenC3
78
79
 
79
80
  # Called by the PluginsController to parse the plugin variables
80
81
  # Doesn't actually create the plugin during the phase
81
- def self.install_phase1(gem_file_path, existing_variables: nil, existing_plugin_txt_lines: nil, store_id: nil, process_existing: false, scope:, validate_only: false)
82
+ def self.install_phase1(gem_file_path, existing_variables: nil, existing_plugin_txt_lines: nil, store_plugin_id: nil, store_version_id: nil, process_existing: false, scope:, validate_only: false)
82
83
  gem_name = File.basename(gem_file_path).split("__")[0]
83
84
 
84
85
  temp_dir = Dir.mktmpdir
@@ -167,8 +168,9 @@ module OpenC3
167
168
  end
168
169
  end
169
170
 
170
- store_id = Integer(store_id) if store_id
171
- model = PluginModel.new(name: gem_name, variables: variables, plugin_txt_lines: plugin_txt_lines, store_id: store_id, minimum_cosmos_version: minimum_cosmos_version, scope: scope)
171
+ store_plugin_id = Integer(store_plugin_id) if store_plugin_id
172
+ store_version_id = Integer(store_version_id) if store_version_id
173
+ model = PluginModel.new(name: gem_name, variables: variables, plugin_txt_lines: plugin_txt_lines, store_plugin_id: store_plugin_id, store_version_id: store_version_id, minimum_cosmos_version: minimum_cosmos_version, scope: scope)
172
174
  result = model.as_json()
173
175
  result['existing_plugin_txt_lines'] = existing_plugin_txt_lines if existing_plugin_txt_lines and not process_existing and existing_plugin_txt_lines != result['plugin_txt_lines']
174
176
  return result
@@ -233,7 +235,8 @@ module OpenC3
233
235
  full_default_path = File.join(gem_path, default_img_path)
234
236
  img_path = default_img_path if File.exist? full_default_path
235
237
  end
236
- plugin_model.img_path = File.join('gems', gem_name.split(".gem")[0], img_path) if img_path # convert this filesystem path to volumes mount path
238
+ package_name = "#{pkg.spec.name}-#{pkg.spec.version}"
239
+ plugin_model.img_path = File.join('gems', package_name, img_path) if img_path # convert this filesystem path to volumes mount path
237
240
  plugin_model.update() unless validate_only
238
241
 
239
242
  needs_dependencies = pkg.spec.runtime_dependencies.length > 0
@@ -265,16 +268,16 @@ module OpenC3
265
268
  if File.exist?(pyproject_path)
266
269
  Logger.info "Installing python packages from pyproject.toml with pypi_url=#{pypi_url}"
267
270
  if ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
268
- pip_args = "--no-warn-script-location -i #{pypi_url} #{gem_path}"
271
+ pip_args = "-i #{pypi_url} #{gem_path}"
269
272
  else
270
- pip_args = "--no-warn-script-location -i #{pypi_url} --trusted-host #{URI.parse(pypi_url).host} #{gem_path}"
273
+ pip_args = "-i #{pypi_url} --trusted-host #{URI.parse(pypi_url).host} #{gem_path}"
271
274
  end
272
275
  else
273
276
  Logger.info "Installing python packages from requirements.txt with pypi_url=#{pypi_url}"
274
277
  if ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
275
- pip_args = "--no-warn-script-location -i #{pypi_url} -r #{requirements_path}"
278
+ pip_args = "-i #{pypi_url} -r #{requirements_path}"
276
279
  else
277
- pip_args = "--no-warn-script-location -i #{pypi_url} --trusted-host #{URI.parse(pypi_url).host} -r #{requirements_path}"
280
+ pip_args = "-i #{pypi_url} --trusted-host #{URI.parse(pypi_url).host} -r #{requirements_path}"
278
281
  end
279
282
  end
280
283
  puts `/openc3/bin/pipinstall #{pip_args}`
@@ -377,7 +380,8 @@ module OpenC3
377
380
  plugin_txt_lines: [],
378
381
  minimum_cosmos_version: nil,
379
382
  needs_dependencies: false,
380
- store_id: nil,
383
+ store_plugin_id: nil,
384
+ store_version_id: nil,
381
385
  title: nil,
382
386
  description: nil,
383
387
  keywords: nil,
@@ -393,7 +397,8 @@ module OpenC3
393
397
  @plugin_txt_lines = plugin_txt_lines
394
398
  @minimum_cosmos_version = minimum_cosmos_version
395
399
  @needs_dependencies = ConfigParser.handle_true_false(needs_dependencies)
396
- @store_id = store_id
400
+ @store_plugin_id = store_plugin_id
401
+ @store_version_id = store_version_id
397
402
  @title = title
398
403
  @description = description
399
404
  @keywords = keywords
@@ -420,7 +425,8 @@ module OpenC3
420
425
  'plugin_txt_lines' => @plugin_txt_lines,
421
426
  'minimum_cosmos_version' => @minimum_cosmos_version,
422
427
  'needs_dependencies' => @needs_dependencies,
423
- 'store_id' => @store_id,
428
+ 'store_plugin_id' => @store_plugin_id,
429
+ 'store_version_id' => @store_version_id,
424
430
  'title' => @title,
425
431
  'description' => @description,
426
432
  'keywords' => @keywords,
@@ -18,7 +18,7 @@ module OpenC3
18
18
  class PluginStoreModel < Model
19
19
  PRIMARY_KEY = 'openc3_plugin_store'
20
20
  DEFAULT_STORE_URL = 'https://store.openc3.com'
21
- JSON_ENDPOINT = '/api/v1.1/cosmos_plugins'
21
+ JSON_ENDPOINT = '/api/v1.2/cosmos_plugins'
22
22
 
23
23
  def self.set(plugin_store_data)
24
24
  Store.set(PRIMARY_KEY, plugin_store_data)
@@ -88,9 +88,9 @@ module OpenC3
88
88
  end
89
89
  Logger.info "Installing python package: #{name_or_path}"
90
90
  if ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
91
- pip_args = ["--no-warn-script-location", "-i", pypi_url, package_file_path]
91
+ pip_args = ["-i", pypi_url, package_file_path]
92
92
  else
93
- pip_args = ["--no-warn-script-location", "-i", pypi_url, "--trusted-host", URI.parse(pypi_url).host, package_file_path]
93
+ pip_args = ["-i", pypi_url, "--trusted-host", URI.parse(pypi_url).host, package_file_path]
94
94
  end
95
95
  result = OpenC3::ProcessManager.instance.spawn(["/openc3/bin/pipinstall"] + pip_args, "package_install", package_filename, Time.now + 3600.0, scope: scope)
96
96
  return result.name
@@ -40,7 +40,7 @@ module OpenC3
40
40
  end
41
41
  # END NOTE
42
42
 
43
- def self.queue_command(name, command: nil, target_name: nil, cmd_name: nil, cmd_params: nil, username:, scope:)
43
+ def self.queue_command(name, command: nil, target_name: nil, cmd_name: nil, cmd_params: nil, validate: true, timeout: nil, username:, scope:)
44
44
  model = get_model(name: name, scope: scope)
45
45
  raise QueueError, "Queue '#{name}' not found in scope '#{scope}'" unless model
46
46
 
@@ -53,7 +53,7 @@ module OpenC3
53
53
  end
54
54
 
55
55
  # Build command data with support for both formats
56
- command_data = { username: username, timestamp: Time.now.to_nsec_from_epoch }
56
+ command_data = { username: username, timestamp: Time.now.to_nsec_from_epoch, validate: validate, timeout: timeout }
57
57
  if target_name && cmd_name
58
58
  # New format: store target_name, cmd_name, and cmd_params separately
59
59
  command_data[:target_name] = target_name
@@ -142,7 +142,7 @@ module OpenC3
142
142
  notify(kind: 'command')
143
143
  end
144
144
 
145
- def update_command(id:, command:, username:)
145
+ def update_command(id:, command:, username:, validate: nil, timeout: nil)
146
146
  if @state == 'DISABLE'
147
147
  raise QueueError, "Queue '#{@name}' is disabled. Command at id #{id} not updated."
148
148
  end
@@ -156,6 +156,8 @@ module OpenC3
156
156
  # Remove the existing command and add the new one at the same id
157
157
  Store.zremrangebyscore("#{@scope}:#{@name}", id, id)
158
158
  command_data = { username: username, value: command, timestamp: Time.now.to_nsec_from_epoch }
159
+ command_data[:validate] = validate unless validate.nil?
160
+ command_data[:timeout] = timeout unless timeout.nil?
159
161
  Store.zadd("#{@scope}:#{@name}", id, command_data.to_json)
160
162
  notify(kind: 'command')
161
163
  end
@@ -13,7 +13,7 @@
13
13
 
14
14
  require 'openc3/top_level'
15
15
  require 'openc3/models/model'
16
- require 'openc3/models/scope_model'
16
+ # require 'openc3/models/scope_model' # Circular require
17
17
  require 'openc3/utilities/bucket'
18
18
  require 'openc3/utilities/bucket_utilities'
19
19
 
@@ -33,6 +33,7 @@ require 'openc3/utilities/bucket'
33
33
  require 'openc3/utilities/zip'
34
34
  require 'fileutils'
35
35
  require 'ostruct'
36
+ require 'set'
36
37
  require 'tmpdir'
37
38
 
38
39
  module OpenC3
@@ -55,6 +56,7 @@ module OpenC3
55
56
  @@sync_packet_count_data = {}
56
57
  @@sync_packet_count_time = nil
57
58
  @@sync_packet_count_delay_seconds = 1.0 # Sync packet counts every second
59
+ @@stale_packet_keys_warned = Set.new # Track stale keys already warned about
58
60
 
59
61
  attr_accessor :folder_name
60
62
  attr_accessor :requires
@@ -162,36 +164,41 @@ module OpenC3
162
164
  if target_name.include?('..') || target_name.include?('/') || target_name.include?('\\')
163
165
  raise ArgumentError, "Invalid target_name: #{target_name.inspect}"
164
166
  end
165
- tmp_dir = Dir.mktmpdir
166
- zip_filename = File.join(tmp_dir, "#{target_name}.zip")
167
- Zip.continue_on_exists_proc = true
168
- zip = Zip::File.open(zip_filename, create: true)
167
+ temp_dir = Dir.mktmpdir
168
+ begin
169
+ zip_filename = OpenC3.sanitize_path(File.join(temp_dir, "#{target_name}.zip"))
170
+ Zip.continue_on_exists_proc = true
171
+ zip = Zip::File.open(zip_filename, create: true)
169
172
 
170
- if ENV['OPENC3_LOCAL_MODE']
171
- OpenC3::LocalMode.zip_target(target_name, zip, scope: scope)
172
- else
173
- bucket = Bucket.getClient()
174
- # The trailing slash is important!
175
- prefix = "#{scope}/targets_modified/#{target_name}/"
176
- resp = bucket.list_objects(
177
- bucket: ENV['OPENC3_CONFIG_BUCKET'],
178
- prefix: prefix,
179
- )
180
- resp.each do |item|
181
- # item.key looks like DEFAULT/targets_modified/INST/screens/blah.txt
182
- base_path = item.key.sub(prefix, '') # remove prefix
183
- local_path = File.join(tmp_dir, base_path)
184
- # Ensure dir structure exists, get_object fails if not
185
- FileUtils.mkdir_p(File.dirname(local_path))
186
- bucket.get_object(bucket: ENV['OPENC3_CONFIG_BUCKET'], key: item.key, path: local_path)
187
- zip.add(base_path, local_path)
173
+ if ENV['OPENC3_LOCAL_MODE']
174
+ OpenC3::LocalMode.zip_target(target_name, zip, scope: scope)
175
+ else
176
+ bucket = Bucket.getClient()
177
+ # The trailing slash is important!
178
+ prefix = "#{scope}/targets_modified/#{target_name}/"
179
+ resp = bucket.list_objects(
180
+ bucket: ENV['OPENC3_CONFIG_BUCKET'],
181
+ prefix: prefix,
182
+ )
183
+ resp.each do |item|
184
+ # item.key looks like DEFAULT/targets_modified/INST/screens/blah.txt
185
+ base_path = item.key.sub(prefix, '') # remove prefix
186
+ local_path = File.join(temp_dir, base_path)
187
+ # Ensure dir structure exists, get_object fails if not
188
+ FileUtils.mkdir_p(File.dirname(local_path))
189
+ bucket.get_object(bucket: ENV['OPENC3_CONFIG_BUCKET'], key: item.key, path: local_path)
190
+ zip.add(base_path, local_path)
191
+ end
188
192
  end
193
+ zip.close
194
+
195
+ result = OpenStruct.new
196
+ result.filename = File.basename(zip_filename)
197
+ result.contents = File.read(zip_filename, mode: 'rb')
198
+ ensure
199
+ FileUtils.remove_entry_secure(temp_dir, true)
189
200
  end
190
- zip.close
191
201
 
192
- result = OpenStruct.new
193
- result.filename = File.basename(zip_filename)
194
- result.contents = File.read(zip_filename, mode: 'rb')
195
202
  return result
196
203
  end
197
204
 
@@ -391,14 +398,7 @@ module OpenC3
391
398
  shard: 0,
392
399
  scope:
393
400
  )
394
- super("#{scope}__#{PRIMARY_KEY}", name: name, plugin: plugin, updated_at: updated_at,
395
- cmd_buffer_depth: cmd_buffer_depth, cmd_log_cycle_time: cmd_log_cycle_time, cmd_log_cycle_size: cmd_log_cycle_size,
396
- cmd_log_retain_time: cmd_log_retain_time,
397
- tlm_buffer_depth: tlm_buffer_depth, tlm_log_cycle_time: tlm_log_cycle_time, tlm_log_cycle_size: tlm_log_cycle_size,
398
- tlm_log_retain_time: tlm_log_retain_time,
399
- cmd_decom_retain_time: cmd_decom_retain_time, tlm_decom_retain_time: tlm_decom_retain_time,
400
- cleanup_poll_time: cleanup_poll_time, needs_dependencies: needs_dependencies, target_microservices: target_microservices,
401
- scope: scope)
401
+ super("#{scope}__#{PRIMARY_KEY}", name: name, plugin: plugin, updated_at: updated_at, scope: scope)
402
402
  @folder_name = folder_name
403
403
  @requires = requires
404
404
  @ignored_parameters = ignored_parameters
@@ -1235,17 +1235,34 @@ module OpenC3
1235
1235
 
1236
1236
  def self.init_tlm_packet_counts(tlm_target_names, scope:)
1237
1237
  @@sync_packet_count_time = Time.now
1238
+ @@stale_packet_keys_warned = Set.new
1238
1239
 
1239
1240
  # Get all the packet counts with the global counters
1240
1241
  tlm_target_names.each do |target_name|
1241
1242
  get_all_telemetry_counts(target_name, scope: scope).each do |packet_name, count|
1242
- update_packet = System.telemetry.packet(target_name, packet_name)
1243
- update_packet.received_count = count.to_i
1243
+ begin
1244
+ update_packet = System.telemetry.packet(target_name, packet_name)
1245
+ update_packet.received_count = count.to_i
1246
+ rescue RuntimeError
1247
+ key = "#{target_name} #{packet_name}"
1248
+ unless @@stale_packet_keys_warned.include?(key)
1249
+ @@stale_packet_keys_warned.add(key)
1250
+ Logger.warn("Stale tlmcnt Redis key detected for unknown packet #{key} - ignoring")
1251
+ end
1252
+ end
1244
1253
  end
1245
1254
  end
1246
1255
  get_all_telemetry_counts('UNKNOWN', scope: scope).each do |packet_name, count|
1247
- update_packet = System.telemetry.packet('UNKNOWN', packet_name)
1248
- update_packet.received_count = count.to_i
1256
+ begin
1257
+ update_packet = System.telemetry.packet('UNKNOWN', packet_name)
1258
+ update_packet.received_count = count.to_i
1259
+ rescue RuntimeError
1260
+ key = "UNKNOWN #{packet_name}"
1261
+ unless @@stale_packet_keys_warned.include?(key)
1262
+ @@stale_packet_keys_warned.add(key)
1263
+ Logger.warn("Stale tlmcnt Redis key detected for unknown packet #{key} - ignoring")
1264
+ end
1265
+ end
1249
1266
  end
1250
1267
  end
1251
1268
 
@@ -1291,14 +1308,30 @@ module OpenC3
1291
1308
  end
1292
1309
  tlm_target_names.each do |target_name|
1293
1310
  result[inc_count].each do |packet_name, count|
1294
- update_packet = System.telemetry.packet(target_name, packet_name)
1295
- update_packet.received_count = count.to_i
1311
+ begin
1312
+ update_packet = System.telemetry.packet(target_name, packet_name)
1313
+ update_packet.received_count = count.to_i
1314
+ rescue RuntimeError
1315
+ key = "#{target_name} #{packet_name}"
1316
+ unless @@stale_packet_keys_warned.include?(key)
1317
+ @@stale_packet_keys_warned.add(key)
1318
+ Logger.warn("Stale tlmcnt Redis key detected for unknown packet #{key} - ignoring")
1319
+ end
1320
+ end
1296
1321
  end
1297
1322
  inc_count += 1
1298
1323
  end
1299
1324
  result[inc_count].each do |packet_name, count|
1300
- update_packet = System.telemetry.packet('UNKNOWN', packet_name)
1301
- update_packet.received_count = count.to_i
1325
+ begin
1326
+ update_packet = System.telemetry.packet('UNKNOWN', packet_name)
1327
+ update_packet.received_count = count.to_i
1328
+ rescue RuntimeError
1329
+ key = "UNKNOWN #{packet_name}"
1330
+ unless @@stale_packet_keys_warned.include?(key)
1331
+ @@stale_packet_keys_warned.add(key)
1332
+ Logger.warn("Stale tlmcnt Redis key detected for unknown packet #{key} - ignoring")
1333
+ end
1334
+ end
1302
1335
  end
1303
1336
  end
1304
1337
  end