openc3 7.0.0.pre.rc3 → 7.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +58 -10
  3. data/bin/pipinstall +38 -6
  4. data/data/config/command_modifiers.yaml +1 -0
  5. data/data/config/interface_modifiers.yaml +1 -1
  6. data/data/config/item_modifiers.yaml +20 -7
  7. data/data/config/table_parameter_modifiers.yaml +3 -1
  8. data/data/config/telemetry.yaml +1 -1
  9. data/lib/openc3/accessors/json_accessor.rb +1 -1
  10. data/lib/openc3/accessors/template_accessor.rb +9 -0
  11. data/lib/openc3/api/tlm_api.rb +3 -3
  12. data/lib/openc3/config/config_parser.rb +4 -4
  13. data/lib/openc3/conversions/conversion.rb +3 -3
  14. data/lib/openc3/core_ext/faraday.rb +4 -0
  15. data/lib/openc3/interfaces/interface.rb +1 -6
  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/decom_microservice.rb +1 -1
  21. data/lib/openc3/microservices/interface_decom_common.rb +22 -8
  22. data/lib/openc3/microservices/interface_microservice.rb +14 -3
  23. data/lib/openc3/microservices/log_microservice.rb +7 -2
  24. data/lib/openc3/microservices/microservice.rb +10 -4
  25. data/lib/openc3/microservices/queue_microservice.rb +3 -0
  26. data/lib/openc3/microservices/scope_cleanup_microservice.rb +116 -1
  27. data/lib/openc3/microservices/text_log_microservice.rb +4 -1
  28. data/lib/openc3/migrations/20260204000000_remove_decom_reducer.rb +2 -0
  29. data/lib/openc3/models/activity_model.rb +15 -3
  30. data/lib/openc3/models/cvt_model.rb +2 -247
  31. data/lib/openc3/models/plugin_model.rb +9 -1
  32. data/lib/openc3/models/plugin_store_model.rb +1 -1
  33. data/lib/openc3/models/python_package_model.rb +1 -1
  34. data/lib/openc3/models/reaction_model.rb +27 -9
  35. data/lib/openc3/models/script_engine_model.rb +1 -1
  36. data/lib/openc3/models/target_model.rb +32 -34
  37. data/lib/openc3/models/tool_model.rb +18 -5
  38. data/lib/openc3/models/trigger_model.rb +25 -8
  39. data/lib/openc3/models/widget_model.rb +1 -2
  40. data/lib/openc3/operators/operator.rb +9 -7
  41. data/lib/openc3/packets/json_packet.rb +2 -0
  42. data/lib/openc3/packets/packet.rb +1 -0
  43. data/lib/openc3/packets/packet_config.rb +28 -12
  44. data/lib/openc3/script/api_shared.rb +39 -2
  45. data/lib/openc3/script/calendar.rb +40 -10
  46. data/lib/openc3/script/extract.rb +46 -13
  47. data/lib/openc3/script/script.rb +19 -0
  48. data/lib/openc3/script/storage.rb +6 -6
  49. data/lib/openc3/system/system.rb +6 -6
  50. data/lib/openc3/tools/cmd_tlm_server/interface_thread.rb +0 -2
  51. data/lib/openc3/top_level.rb +15 -63
  52. data/lib/openc3/topics/decom_interface_topic.rb +19 -4
  53. data/lib/openc3/topics/interface_topic.rb +21 -2
  54. data/lib/openc3/topics/limits_event_topic.rb +1 -1
  55. data/lib/openc3/utilities/bucket_utilities.rb +3 -1
  56. data/lib/openc3/utilities/cli_generator.rb +7 -0
  57. data/lib/openc3/utilities/cmd_log.rb +1 -1
  58. data/lib/openc3/utilities/ctrf.rb +231 -0
  59. data/lib/openc3/utilities/local_mode.rb +3 -0
  60. data/lib/openc3/utilities/process_manager.rb +1 -1
  61. data/lib/openc3/utilities/python_proxy.rb +11 -4
  62. data/lib/openc3/utilities/questdb_client.rb +739 -22
  63. data/lib/openc3/utilities/running_script.rb +25 -7
  64. data/lib/openc3/utilities/script.rb +452 -0
  65. data/lib/openc3/utilities/secrets.rb +1 -1
  66. data/lib/openc3/version.rb +6 -6
  67. data/templates/conversion/conversion.py +0 -8
  68. data/templates/conversion/conversion.rb +0 -11
  69. data/templates/tool_angular/package.json +2 -2
  70. data/templates/tool_react/package.json +1 -1
  71. data/templates/tool_svelte/package.json +1 -1
  72. data/templates/tool_vue/package.json +3 -4
  73. data/templates/widget/package.json +2 -2
  74. metadata +17 -2
  75. data/lib/openc3/migrations/20251022000000_remove_unique_id.rb +0 -23
@@ -179,7 +179,12 @@ module OpenC3
179
179
  next 'SUCCESS'
180
180
  end
181
181
  if msg_hash.key?('inject_tlm')
182
- handle_inject_tlm(msg_hash['inject_tlm'])
182
+ begin
183
+ handle_inject_tlm(msg_hash['inject_tlm'])
184
+ rescue => e
185
+ @logger.error "#{@interface.name}: inject_tlm: #{e.formatted}"
186
+ next e.message
187
+ end
183
188
  next 'SUCCESS'
184
189
  end
185
190
  if msg_hash.key?('release_critical')
@@ -779,7 +784,6 @@ module OpenC3
779
784
  else
780
785
  @logger.error "#{@interface.name}: #{connect_error.formatted}"
781
786
  unless @connection_failed_messages.include?(connect_error.message)
782
- OpenC3.write_exception_file(connect_error)
783
787
  @connection_failed_messages << connect_error.message
784
788
  end
785
789
  end
@@ -800,7 +804,6 @@ module OpenC3
800
804
  else
801
805
  @logger.error "#{@interface.name}: #{err.formatted}"
802
806
  unless @connection_lost_messages.include?(err.message)
803
- OpenC3.write_exception_file(err)
804
807
  @connection_lost_messages << err.message
805
808
  end
806
809
  end
@@ -888,6 +891,14 @@ module OpenC3
888
891
  def shutdown(_sig = nil)
889
892
  @logger.info "#{@interface ? @interface.name : @name}: shutdown requested"
890
893
  stop()
894
+ if @interface and @interface.stream_log_pair
895
+ threads = @interface.stream_log_pair.shutdown
896
+ # Wait for all the logging threads to move files to buckets
897
+ threads.flatten.compact.each do |thread|
898
+ thread.join
899
+ end
900
+ @interface.stream_log_pair.cleanup
901
+ end
891
902
  super()
892
903
  end
893
904
 
@@ -125,8 +125,8 @@ module OpenC3
125
125
  def shutdown
126
126
  # Make sure all the existing logs are properly closed down
127
127
  threads = []
128
- @plws.each do |target_name, plw_hash|
129
- plw_hash.each do |type, plw|
128
+ @plws.each do |_target_name, plw_hash|
129
+ plw_hash.each do |_type, plw|
130
130
  threads.concat(plw.shutdown)
131
131
  end
132
132
  end
@@ -134,6 +134,11 @@ module OpenC3
134
134
  threads.flatten.compact.each do |thread|
135
135
  thread.join
136
136
  end
137
+ @plws.each do |_target_name, plw_hash|
138
+ plw_hash.each do |_type, plw|
139
+ plw.cleanup
140
+ end
141
+ end
137
142
  super()
138
143
  end
139
144
  end
@@ -88,6 +88,7 @@ module OpenC3
88
88
  @name = name
89
89
  split_name = name.split("__")
90
90
  raise "Name #{name} doesn't match convention of SCOPE__TYPE__NAME" if split_name.length != 3
91
+ microservice_type = split_name[1].to_s.upcase
91
92
 
92
93
  @scope = split_name[0]
93
94
  $openc3_scope = @scope
@@ -102,8 +103,14 @@ module OpenC3
102
103
 
103
104
  OpenC3.setup_open_telemetry(@name, false)
104
105
 
106
+ @temp_dir = OpenC3.sanitize_path(File.join(Dir.tmpdir, @name))
107
+
105
108
  # Create temp folder for this microservice
106
- @temp_dir = Dir.mktmpdir
109
+ # This will already have been setup by plugin_microservice.rb if USER
110
+ if is_plugin or microservice_type != 'USER'
111
+ FileUtils.remove_entry_secure(@temp_dir, true)
112
+ Dir.mkdir(@temp_dir)
113
+ end
107
114
 
108
115
  # Get microservice configuration from Redis
109
116
  @config = MicroserviceModel.get(name: @name, scope: @scope)
@@ -142,14 +149,13 @@ module OpenC3
142
149
  cmd_array = @config["cmd"]
143
150
 
144
151
  # Get Microservice files from bucket storage
145
- temp_dir = Dir.mktmpdir
146
152
  bucket = ENV['OPENC3_CONFIG_BUCKET']
147
153
  client = Bucket.getClient()
148
154
 
149
155
  prefix = "#{@scope}/microservices/#{@name}/"
150
156
  file_count = 0
151
157
  client.list_objects(bucket: bucket, prefix: prefix).each do |object|
152
- response_target = File.join(temp_dir, object.key.split(prefix)[-1])
158
+ response_target = OpenC3.sanitize_path(File.join(@temp_dir, object.key.split(prefix)[-1]))
153
159
  FileUtils.mkdir_p(File.dirname(response_target))
154
160
  client.get_object(bucket: bucket, key: object.key, path: response_target)
155
161
  file_count += 1
@@ -157,7 +163,7 @@ module OpenC3
157
163
 
158
164
  # Adjust @work_dir to microservice files downloaded if files and a relative path
159
165
  if file_count > 0 and @work_dir[0] != '/'
160
- @work_dir = File.join(temp_dir, @work_dir)
166
+ @work_dir = OpenC3.sanitize_path(File.join(@temp_dir, @work_dir))
161
167
  end
162
168
 
163
169
  # Check Syntax on any ruby files
@@ -18,6 +18,8 @@ require 'openc3/utilities/authentication'
18
18
  require 'openc3/api/api'
19
19
 
20
20
  module OpenC3
21
+ saved_verbose = $VERBOSE
22
+ $VERBOSE = false
21
23
  module Script
22
24
  private
23
25
  # Override the prompt_for_hazardous method to always return true since there is no user to prompt
@@ -25,6 +27,7 @@ module OpenC3
25
27
  return true
26
28
  end
27
29
  end
30
+ $VERBOSE = saved_verbose
28
31
 
29
32
  # The queue processor runs in a single thread and processes commands via cmd_api.
30
33
  class QueueProcessor
@@ -16,6 +16,120 @@ require 'openc3/microservices/cleanup_microservice'
16
16
 
17
17
  module OpenC3
18
18
  class ScopeCleanupMicroservice < CleanupMicroservice
19
+ TSDB_HEALTH_QUERY =
20
+ "SELECT
21
+ table_name,
22
+ table_row_count,
23
+ wal_pending_row_count,
24
+ CASE
25
+ WHEN table_suspended THEN 'SUSPENDED'
26
+ WHEN table_memory_pressure_level = 2 THEN 'BACKOFF'
27
+ WHEN table_memory_pressure_level = 1 THEN 'PRESSURE'
28
+ ELSE 'OK'
29
+ END AS status,
30
+ wal_txn - table_txn AS lag_txns,
31
+ table_write_amp_p50 AS write_amp,
32
+ table_merge_rate_p99 AS slowest_merge
33
+ FROM tables()
34
+ WHERE walEnabled
35
+ ORDER BY
36
+ table_suspended DESC,
37
+ table_memory_pressure_level DESC,
38
+ wal_pending_row_count DESC;"
39
+
40
+ GROWTH_NUM_SAMPLE_PERIODS = 4
41
+
42
+ def initialize(*args)
43
+ super(*args)
44
+ @run_time = nil
45
+ @cleanup_poll_time = nil
46
+ @delta_time = 0.0
47
+ @wal_pending_row_count = {}
48
+ @lag_txns = {}
49
+ end
50
+
51
+ def cleanup(areas, bucket)
52
+ current_time = Time.now
53
+ if @run_time
54
+ delta = current_time - @run_time
55
+ if delta > 0.0
56
+ @delta_time += delta
57
+ end
58
+ end
59
+ @run_time = current_time
60
+ if @delta_time > @cleanup_poll_time
61
+ @delta_time = 0.0
62
+ super(areas, bucket)
63
+ end
64
+
65
+ # Always check TSDB health
66
+ if @scope == 'DEFAULT'
67
+ begin
68
+ conn = OpenC3::QuestDBClient.connection
69
+ result = conn.exec(TSDB_HEALTH_QUERY)
70
+ columns = result.fields
71
+ rows = result.values
72
+
73
+ table_name_column = columns.index("table_name")
74
+ wal_pending_row_count_column = columns.index("wal_pending_row_count")
75
+ status_column = columns.index("status")
76
+ lag_txns_column = columns.index("lag_txns")
77
+
78
+ rows.each do |values|
79
+ table_name = values[table_name_column]
80
+ wal_pending_row_count = values[wal_pending_row_count_column].to_i
81
+ status = values[status_column]
82
+ lag_txns = values[lag_txns_column].to_i
83
+
84
+ if status != 'OK'
85
+ @logger.error("QuestDB: #{table_name} in bad state: #{status}")
86
+
87
+ if status == 'SUSPENDED'
88
+ # Try to automatically unsuspend
89
+ @logger.info("QuestDB: Attempting to unsuspend: #{table_name}")
90
+ conn.exec("ALTER TABLE #{table_name} RESUME WAL;")
91
+ end
92
+ end
93
+
94
+ @wal_pending_row_count[table_name] ||= []
95
+ @wal_pending_row_count[table_name] << wal_pending_row_count
96
+ @lag_txns[table_name] ||= []
97
+ @lag_txns[table_name] << lag_txns
98
+
99
+ if @wal_pending_row_count[table_name].length > GROWTH_NUM_SAMPLE_PERIODS
100
+ if detect_growth(@wal_pending_row_count[table_name], GROWTH_NUM_SAMPLE_PERIODS)
101
+ # Crossed threshold of sample periods of growth
102
+ @logger.error("QuestDB: #{table_name} has growing wal_pending_row_count: #{wal_pending_row_count}")
103
+ end
104
+
105
+ # Leave the last GROWTH_NUM_SAMPLE_PERIODS samples
106
+ @wal_pending_row_count[table_name] = @wal_pending_row_count[table_name][-GROWTH_NUM_SAMPLE_PERIODS..-1]
107
+ end
108
+
109
+ if @lag_txns[table_name].length > GROWTH_NUM_SAMPLE_PERIODS
110
+ if detect_growth(@lag_txns[table_name], GROWTH_NUM_SAMPLE_PERIODS)
111
+ # Crossed threshold of sample periods of growth
112
+ @logger.error("QuestDB: #{table_name} has growing lag_txns: #{lag_txns}")
113
+ end
114
+
115
+ # Leave the last GROWTH_NUM_SAMPLE_PERIODS samples
116
+ @lag_txns[table_name] = @lag_txns[table_name][-GROWTH_NUM_SAMPLE_PERIODS..-1]
117
+ end
118
+ end
119
+ rescue => e
120
+ OpenC3::QuestDBClient.disconnect
121
+ @logger.error("QuestDB Error: #{e.formatted}")
122
+ end
123
+ end
124
+ end
125
+
126
+ def detect_growth(array, num_samples)
127
+ num_samples.times do |index|
128
+ return false if array[index + 1] <= array[index]
129
+ end
130
+ return true
131
+ end
132
+
19
133
  def get_areas_and_poll_time
20
134
  scope = ScopeModel.get_model(name: @scope)
21
135
  areas = [
@@ -28,7 +142,8 @@ module OpenC3
28
142
  areas << ["NOSCOPE/tool_logs/sr", scope.tool_log_retain_time]
29
143
  end
30
144
 
31
- return areas, scope.cleanup_poll_time
145
+ @cleanup_poll_time = scope.cleanup_poll_time
146
+ return areas, 60 # Run every 1 minute for TSDB checks
32
147
  end
33
148
  end
34
149
  end
@@ -98,13 +98,16 @@ module OpenC3
98
98
  def shutdown
99
99
  # Make sure all the existing logs are properly closed down
100
100
  threads = []
101
- @tlws.each do |topic, tlw|
101
+ @tlws.each do |_topic, tlw|
102
102
  threads.concat(tlw.shutdown)
103
103
  end
104
104
  # Wait for all the logging threads to move files to buckets
105
105
  threads.flatten.compact.each do |thread|
106
106
  thread.join
107
107
  end
108
+ @tlws.each do |_topic, tlw|
109
+ tlw.cleanup
110
+ end
108
111
  super()
109
112
  end
110
113
  end
@@ -23,6 +23,8 @@ module OpenC3
23
23
  target_models = TargetModel.all(scope: scope)
24
24
  target_models.each do |name, target_model|
25
25
  # Remove deprecated decom log settings from target model
26
+ target_model.delete("cmd_unique_id_mode")
27
+ target_model.delete("tlm_unique_id_mode")
26
28
  target_model.delete("cmd_decom_log_cycle_time")
27
29
  target_model.delete("cmd_decom_log_cycle_size")
28
30
  target_model.delete("cmd_decom_log_retain_time")
@@ -18,6 +18,7 @@
18
18
  # https://www.rubydoc.info/gems/redis/Redis/Commands/SortedSets
19
19
 
20
20
  require 'openc3/models/model'
21
+ require 'openc3/models/timeline_model'
21
22
  require 'openc3/topics/timeline_topic'
22
23
  require 'securerandom'
23
24
 
@@ -28,6 +29,11 @@ module OpenC3
28
29
 
29
30
  class ActivityModel < Model
30
31
  MAX_DURATION = Time::SEC_PER_DAY
32
+ # Grace window (in seconds) to allow creating activities slightly in the past.
33
+ # This handles race conditions where real-time activity notifications arrive
34
+ # after the start time has already passed (e.g. from external systems).
35
+ # This is consistent with the -15 second window in the timeline microservice.
36
+ START_GRACE_SECONDS = 15
31
37
  PRIMARY_KEY = '__openc3_timelines'.freeze # MUST be equal to `TimelineModel::PRIMARY_KEY` minus the leading __
32
38
  # See run_activity(activity) in openc3/lib/openc3/microservices/timeline_microservice.rb
33
39
  VALID_KINDS = %w(command script reserve expire)
@@ -212,7 +218,7 @@ module OpenC3
212
218
  end
213
219
 
214
220
  # validate the input to the rules we have created for timelines.
215
- # - A task's start MUST NOT be in the past.
221
+ # - A task's start MUST NOT be more than START_GRACE_SECONDS in the past.
216
222
  # - A task's start MUST be before the stop.
217
223
  # - A task CAN NOT be longer than MAX_DURATION (86400) in seconds.
218
224
  # - A task MUST have a kind.
@@ -230,8 +236,8 @@ module OpenC3
230
236
  rescue NoMethodError
231
237
  raise ActivityInputError.new "start and stop must be seconds: #{start}, #{stop}"
232
238
  end
233
- if now_f >= start and kind != 'expire'
234
- raise ActivityInputError.new "activity must be in the future, current_time: #{now_f} vs #{start}"
239
+ if now_f >= start + START_GRACE_SECONDS and kind != 'expire'
240
+ raise ActivityInputError.new "activity must not be more than #{START_GRACE_SECONDS} seconds in the past, current_time: #{now_f} vs #{start}"
235
241
  elsif duration > MAX_DURATION and kind != 'expire'
236
242
  raise ActivityInputError.new "activity can not be longer than #{MAX_DURATION} seconds"
237
243
  elsif duration <= 0
@@ -262,6 +268,12 @@ module OpenC3
262
268
  # Update the Redis hash at primary_key and set the score equal to the start Epoch time
263
269
  # the member is set to the JSON generated via calling as_json
264
270
  def create(overlap: true, username: nil)
271
+ # Validate that the timeline exists in this scope before creating activities.
272
+ # Activities must be attached to an existing timeline within the same scope.
273
+ unless TimelineModel.get(name: @name, scope: @scope)
274
+ raise ActivityError.new "timeline '#{@name}' does not exist in scope '#{@scope}'"
275
+ end
276
+
265
277
  if @recurring['end'] and @recurring['frequency'] and @recurring['span']
266
278
  # First validate the initial recurring activity ... all others are just offsets
267
279
  validate_input(start: @start, stop: @stop, kind: @kind, data: @data)
@@ -15,11 +15,10 @@
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 'set'
19
18
  require 'openc3/utilities/store'
20
19
  require 'openc3/utilities/store_queued'
21
20
  require 'openc3/utilities/questdb_client'
22
- require 'openc3/models/target_model'
21
+ # require 'openc3/models/target_model' # Circular require
23
22
 
24
23
  module OpenC3
25
24
  class CvtModel
@@ -126,251 +125,7 @@ module OpenC3
126
125
  end
127
126
 
128
127
  def self.tsdb_lookup(items, start_time:, end_time: nil, scope: $openc3_scope)
129
- tables = {}
130
- names = []
131
- nil_count = 0
132
- # Cache packet definitions to avoid repeated lookups
133
- packet_cache = {}
134
- # Map column names to item type info for decoding
135
- item_types = {}
136
- # Track calculated timestamp items: { position => { source:, format:, table_index: } }
137
- calculated_items = {}
138
- # Track which timestamp columns we need per table
139
- needed_timestamps = {} # { table_index => Set of column names }
140
- current_position = 0
141
-
142
- # Stored timestamp items that need conversion from timestamp_ns to float seconds
143
- stored_timestamp_items = Set.new(['PACKET_TIMESECONDS', 'RECEIVED_TIMESECONDS'])
144
- # Track stored timestamp items: { position => { column:, table_index: } }
145
- stored_timestamp_positions = {}
146
-
147
- items.each do |item|
148
- target_name, packet_name, orig_item_name, value_type, limits = item
149
- # They will all be nil when item is a nil value
150
- # A nil value indicates a value that does not exist as returned by get_tlm_available
151
- if orig_item_name.nil?
152
- # We know PACKET_TIMESECONDS always exists so we can use it to fill in the nil value
153
- names << "PACKET_TIMESECONDS as __nil#{nil_count}"
154
- nil_count += 1
155
- current_position += 1
156
- next
157
- end
158
- table_name = QuestDBClient.sanitize_table_name(target_name, packet_name, scope: scope)
159
- tables[table_name] = 1
160
- index = tables.find_index {|k,v| k == table_name }
161
-
162
- # Check if this is a stored timestamp item (PACKET_TIMESECONDS or RECEIVED_TIMESECONDS)
163
- # These are stored as timestamp_ns columns and need conversion to float seconds on read
164
- if stored_timestamp_items.include?(orig_item_name)
165
- col_name = "T#{index}.#{orig_item_name}"
166
- names << "\"#{col_name}\""
167
- stored_timestamp_positions[current_position] = { column: col_name, table_index: index }
168
- current_position += 1
169
- next
170
- end
171
-
172
- # Check if this is a calculated timestamp item (PACKET_TIMEFORMATTED or RECEIVED_TIMEFORMATTED)
173
- if QuestDBClient::TIMESTAMP_ITEMS.key?(orig_item_name)
174
- ts_info = QuestDBClient::TIMESTAMP_ITEMS[orig_item_name]
175
- calculated_items[current_position] = {
176
- source: ts_info[:source],
177
- format: ts_info[:format],
178
- table_index: index
179
- }
180
- # Track that we need this timestamp column for this table
181
- needed_timestamps[index] ||= Set.new
182
- needed_timestamps[index] << ts_info[:source]
183
- current_position += 1
184
- next
185
- end
186
-
187
- safe_item_name = QuestDBClient.sanitize_column_name(orig_item_name)
188
-
189
- # Look up item type info from packet definition
190
- cache_key = [target_name, packet_name]
191
- unless packet_cache.key?(cache_key)
192
- begin
193
- packet_cache[cache_key] = TargetModel.packet(target_name, packet_name, scope: scope)
194
- rescue RuntimeError
195
- packet_cache[cache_key] = nil
196
- end
197
- end
198
-
199
- packet_def = packet_cache[cache_key]
200
- item_def = nil
201
- if packet_def
202
- packet_def['items']&.each do |pkt_item|
203
- if pkt_item['name'] == orig_item_name
204
- item_def = pkt_item
205
- break
206
- end
207
- end
208
- end
209
-
210
- case value_type
211
- when 'FORMATTED', 'WITH_UNITS'
212
- col_name = "T#{index}.#{safe_item_name}__F"
213
- names << "\"#{col_name}\""
214
- # Formatted values are always strings, no special decoding needed
215
- item_types[col_name] = { 'data_type' => 'STRING', 'array_size' => nil }
216
- when 'CONVERTED'
217
- col_name = "T#{index}.#{safe_item_name}__C"
218
- names << "\"#{col_name}\""
219
- # Converted values may have different types based on read_conversion
220
- if item_def
221
- rc = item_def['read_conversion']
222
- if rc && rc['converted_type']
223
- item_types[col_name] = { 'data_type' => rc['converted_type'], 'array_size' => item_def['array_size'] }
224
- elsif item_def['states']
225
- # State values are strings
226
- item_types[col_name] = { 'data_type' => 'STRING', 'array_size' => nil }
227
- else
228
- item_types[col_name] = { 'data_type' => item_def['data_type'], 'array_size' => item_def['array_size'] }
229
- end
230
- else
231
- item_types[col_name] = { 'data_type' => nil, 'array_size' => nil }
232
- end
233
- else
234
- col_name = "T#{index}.#{safe_item_name}"
235
- names << "\"#{col_name}\""
236
- if item_def
237
- item_types[col_name] = { 'data_type' => item_def['data_type'], 'array_size' => item_def['array_size'] }
238
- else
239
- item_types[col_name] = { 'data_type' => nil, 'array_size' => nil }
240
- end
241
- end
242
- current_position += 1
243
- if limits
244
- names << "\"T#{index}.#{safe_item_name}__L\""
245
- end
246
- end
247
-
248
- # Add needed timestamp columns to the SELECT
249
- # Track which column alias maps to which timestamp source for result processing
250
- # Note: We use underscores in the alias name to avoid needing quotes, which QuestDB includes in returned field names
251
- timestamp_columns = {} # { "T0___ts_timestamp" => { table_index: 0, source: 'timestamp' } }
252
- needed_timestamps.each do |table_index, ts_columns|
253
- ts_columns.each do |ts_col|
254
- alias_name = "T#{table_index}___ts_#{ts_col}"
255
- names << "T#{table_index}.#{ts_col} as #{alias_name}"
256
- timestamp_columns[alias_name] = { table_index: table_index, source: ts_col }
257
- end
258
- end
259
-
260
- # Build the SQL query
261
- query = "SELECT #{names.join(", ")} FROM "
262
- tables.each_with_index do |(table_name, _), index|
263
- if index == 0
264
- query += "#{table_name} as T#{index} "
265
- else
266
- query += "ASOF JOIN #{table_name} as T#{index} "
267
- end
268
- end
269
- query_params = []
270
- if start_time && !end_time
271
- query += "WHERE T0.PACKET_TIMESECONDS < $1 LIMIT -1"
272
- query_params << start_time
273
- elsif start_time && end_time
274
- query += "WHERE T0.PACKET_TIMESECONDS >= $1 AND T0.PACKET_TIMESECONDS < $2"
275
- query_params << start_time
276
- query_params << end_time
277
- end
278
-
279
- retry_count = 0
280
- begin
281
- conn = QuestDBClient.connection
282
- result = conn.exec_params(query, query_params)
283
- if result.nil? or result.ntuples == 0
284
- return {}
285
- else
286
- data = []
287
- # Build up a results set that is an array of arrays
288
- # Each nested array is a set of 2 items: [value, limits state]
289
- # If the item does not have limits the limits state is nil
290
- result.each_with_index do |tuples, row_num|
291
- data[row_num] ||= []
292
- row_index = 0
293
- # Store timestamp values for this row: { "T0.PACKET_TIMESECONDS" => Time, ... }
294
- row_timestamps = {}
295
- tuples.each do |tuple|
296
- col_name = tuple[0]
297
- col_value = tuple[1]
298
- if col_name.include?("__L")
299
- data[row_num][row_index - 1][1] = col_value
300
- elsif col_name =~ /^__nil/
301
- data[row_num][row_index] = [nil, nil]
302
- row_index += 1
303
- elsif col_name =~ /^T(\d+)___ts_(.+)$/
304
- # This is a timestamp column for calculated items (TIMEFORMATTED)
305
- table_idx = $1.to_i
306
- ts_source = $2
307
- row_timestamps["T#{table_idx}.#{ts_source}"] = col_value
308
- elsif col_name.end_with?('.PACKET_TIMESECONDS', '.RECEIVED_TIMESECONDS') || col_name == 'PACKET_TIMESECONDS' || col_name == 'RECEIVED_TIMESECONDS'
309
- # Stored timestamp column - convert from datetime to float seconds
310
- ts_utc = QuestDBClient.pg_timestamp_to_utc(col_value)
311
- seconds_value = QuestDBClient.format_timestamp(ts_utc, :seconds)
312
- data[row_num][row_index] = [seconds_value, nil]
313
- row_index += 1
314
- # Also store for calculated items (TIMEFORMATTED) that may need this
315
- # Normalize key to T{index}.{col} format for consistency
316
- if col_name.include?('.')
317
- row_timestamps[col_name] = col_value
318
- else
319
- row_timestamps["T0.#{col_name}"] = col_value
320
- end
321
- else
322
- # Decode value using item type info
323
- # QuestDB may return column names without table alias prefix
324
- # Try both the raw column name and prefixed versions
325
- type_info = item_types[col_name]
326
- unless type_info
327
- tables.length.times do |i|
328
- prefixed_name = "T#{i}.#{col_name}"
329
- type_info = item_types[prefixed_name]
330
- break if type_info
331
- end
332
- type_info ||= {}
333
- end
334
- decoded_value = QuestDBClient.decode_value(
335
- col_value,
336
- data_type: type_info['data_type'],
337
- array_size: type_info['array_size']
338
- )
339
- data[row_num][row_index] = [decoded_value, nil]
340
- row_index += 1
341
- end
342
- end
343
-
344
- # Insert calculated timestamp items at their positions
345
- # Insert in ascending order so positions remain valid after each insert
346
- calculated_items.keys.sort.each do |position|
347
- calc_info = calculated_items[position]
348
- ts_key = "T#{calc_info[:table_index]}.#{calc_info[:source]}"
349
- ts_value = row_timestamps[ts_key]
350
- ts_utc = QuestDBClient.pg_timestamp_to_utc(ts_value)
351
- calculated_value = QuestDBClient.format_timestamp(ts_utc, calc_info[:format])
352
- data[row_num].insert(position, [calculated_value, nil])
353
- end
354
- end
355
- # If we only have one row then we return a single array
356
- if result.ntuples == 1
357
- data = data[0]
358
- end
359
- return data
360
- end
361
- rescue IOError, PG::Error => e
362
- # Retry the query because various errors can occur that are recoverable
363
- retry_count += 1
364
- if retry_count > 4
365
- # After the 5th retry just raise the error
366
- raise "Error querying TSDB: #{e.message}"
367
- end
368
- Logger.warn("TSDB: Retrying due to error: #{e.message}")
369
- Logger.warn("TSDB: Last query: #{query}") # Log the last query for debugging
370
- QuestDBClient.disconnect
371
- sleep 0.1
372
- retry
373
- end
128
+ QuestDBClient.tsdb_lookup(items, start_time: start_time, end_time: end_time, scope: scope)
374
129
  end
375
130
 
376
131
  # Return all item values and limit state from the CVT
@@ -280,7 +280,15 @@ module OpenC3
280
280
  pip_args = "-i #{pypi_url} --trusted-host #{URI.parse(pypi_url).host} -r #{requirements_path}"
281
281
  end
282
282
  end
283
- puts `/openc3/bin/pipinstall #{pip_args}`
283
+ # Capture output and check exit code so failures surface as a warning
284
+ # rather than silently succeeding. pipinstall is non-fatal: the plugin
285
+ # continues to install even if Python packages fail so that non-Python
286
+ # functionality still works.
287
+ output = `/openc3/bin/pipinstall #{pip_args}`
288
+ puts output
289
+ unless $?.success?
290
+ Logger.warn "Python package installation failed. Plugin Python microservices may not function correctly."
291
+ end
284
292
  end
285
293
  needs_dependencies = true
286
294
  end
@@ -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)
@@ -99,7 +99,7 @@ module OpenC3
99
99
  def self.destroy(name, scope:)
100
100
  package_name, version = self.extract_name_and_version(name)
101
101
  Logger.info "Uninstalling package: #{name}"
102
- pip_args = ["-y", package_name]
102
+ pip_args = [package_name]
103
103
  result = OpenC3::ProcessManager.instance.spawn(["/openc3/bin/pipuninstall"] + pip_args, "package_uninstall", name, Time.now + 3600.0, scope: scope)
104
104
  return result.name
105
105
  end