openc3 6.6.0 → 6.8.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +77 -16
  3. data/data/config/command_modifiers.yaml +3 -3
  4. data/data/config/interface_modifiers.yaml +1 -1
  5. data/data/config/table_manager.yaml +1 -1
  6. data/data/config/telemetry_modifiers.yaml +3 -3
  7. data/data/config/widgets.yaml +2 -2
  8. data/lib/openc3/accessors.rb +1 -1
  9. data/lib/openc3/api/cmd_api.rb +15 -4
  10. data/lib/openc3/api/settings_api.rb +8 -0
  11. data/lib/openc3/api/stash_api.rb +1 -1
  12. data/lib/openc3/api/tlm_api.rb +96 -14
  13. data/lib/openc3/core_ext/kernel.rb +3 -3
  14. data/lib/openc3/logs/log_writer.rb +16 -12
  15. data/lib/openc3/microservices/interface_microservice.rb +14 -1
  16. data/lib/openc3/microservices/plugin_microservice.rb +2 -2
  17. data/lib/openc3/microservices/queue_microservice.rb +166 -0
  18. data/lib/openc3/models/cvt_model.rb +140 -3
  19. data/lib/openc3/models/plugin_model.rb +7 -2
  20. data/lib/openc3/models/plugin_store_model.rb +70 -0
  21. data/lib/openc3/models/queue_model.rb +232 -0
  22. data/lib/openc3/models/target_model.rb +26 -0
  23. data/lib/openc3/models/tool_model.rb +1 -1
  24. data/lib/openc3/packets/packet.rb +3 -3
  25. data/lib/openc3/packets/parsers/state_parser.rb +7 -1
  26. data/lib/openc3/packets/structure.rb +9 -2
  27. data/lib/openc3/script/calendar.rb +10 -10
  28. data/lib/openc3/script/commands.rb +4 -4
  29. data/lib/openc3/script/queue.rb +80 -0
  30. data/lib/openc3/script/script.rb +1 -0
  31. data/lib/openc3/script/script_runner.rb +7 -2
  32. data/lib/openc3/script/tables.rb +3 -3
  33. data/lib/openc3/script/web_socket_api.rb +11 -0
  34. data/lib/openc3/topics/queue_topic.rb +29 -0
  35. data/lib/openc3/utilities/authorization.rb +1 -1
  36. data/lib/openc3/utilities/cosmos_rails_formatter.rb +1 -1
  37. data/lib/openc3/utilities/local_mode.rb +2 -0
  38. data/lib/openc3/utilities/logger.rb +1 -1
  39. data/lib/openc3/utilities/running_script.rb +5 -1
  40. data/lib/openc3/version.rb +5 -5
  41. data/templates/tool_angular/package.json +2 -2
  42. data/templates/tool_react/package.json +1 -1
  43. data/templates/tool_svelte/package.json +1 -1
  44. data/templates/tool_vue/package.json +3 -3
  45. data/templates/widget/package.json +2 -2
  46. metadata +83 -8
@@ -0,0 +1,166 @@
1
+ # encoding: ascii-8bit
2
+
3
+ # Copyright 2025 OpenC3, Inc.
4
+ # All Rights Reserved.
5
+ #
6
+ # This program is free software; you can modify and/or redistribute it
7
+ # under the terms of the GNU Affero General Public License
8
+ # as published by the Free Software Foundation; version 3 with
9
+ # attribution addendums as found in the LICENSE.txt
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU Affero General Public License for more details.
15
+ #
16
+ # This file may also be used under the terms of a commercial license
17
+ # if purchased from OpenC3, Inc.
18
+
19
+ require 'openc3/microservices/microservice'
20
+ require 'openc3/topics/queue_topic'
21
+ require 'openc3/utilities/authentication'
22
+ require 'openc3/script'
23
+
24
+ module OpenC3
25
+ # The queue processor runs in a single thread and processes commands via cmd_api.
26
+ class QueueProcessor
27
+ attr_accessor :state
28
+ attr_reader :name, :scope
29
+
30
+ def initialize(name:, state:, logger:, scope:)
31
+ @name = name
32
+ @logger = logger
33
+ @scope = scope
34
+ @state = state
35
+ @cancel_thread = false
36
+ end
37
+
38
+ def get_token(username)
39
+ if ENV['OPENC3_API_CLIENT'].nil?
40
+ ENV['OPENC3_API_PASSWORD'] ||= ENV['OPENC3_SERVICE_PASSWORD']
41
+ return OpenC3Authentication.new().token
42
+ else
43
+ # Check for offline access token
44
+ model = nil
45
+ model = OpenC3::OfflineAccessModel.get_model(name: username, scope: @scope) if username and username != ''
46
+ if model and model.offline_access_token
47
+ auth = OpenC3KeycloakAuthentication.new(ENV['OPENC3_KEYCLOAK_URL'])
48
+ return auth.get_token_from_refresh_token(model.offline_access_token)
49
+ else
50
+ return nil
51
+ end
52
+ end
53
+ end
54
+
55
+ def run
56
+ while true
57
+ if @state == 'RELEASE'
58
+ process_queued_commands()
59
+ else
60
+ sleep 0.2
61
+ end
62
+ break if @cancel_thread
63
+ end
64
+ end
65
+
66
+ def process_queued_commands
67
+ while @state == 'RELEASE'
68
+ begin
69
+ _queue_name, command_data, _timestamp = Store.bzpopmin("#{@scope}:#{@name}", timeout: 0.2)
70
+ if command_data
71
+ command = JSON.parse(command_data)
72
+ username = command['username']
73
+ token = get_token(username)
74
+ # It's important to set queue: false here to avoid infinite recursion when
75
+ # OPENC3_DEFAULT_QUEUE is set because commands would be re-queued to the default queue
76
+ cmd_no_hazardous_check(command['value'], queue: false, scope: @scope, token: token)
77
+ end
78
+ rescue StandardError => e
79
+ @logger.error "QueueProcessor failed to process command from queue #{@name}\n#{e.message}"
80
+ end
81
+ break if @cancel_thread
82
+ end
83
+ end
84
+
85
+ def shutdown
86
+ @cancel_thread = true
87
+ end
88
+ end
89
+
90
+ # The queue microservice starts a processor then gets the queue entries from redis.
91
+ # It then monitors the QueueTopic for changes.
92
+ class QueueMicroservice < Microservice
93
+ attr_reader :name, :processor, :processor_thread
94
+
95
+ def initialize(*args)
96
+ super(*args)
97
+ @queue_name = @name.split('__')[2]
98
+
99
+ initial_state = 'HOLD'
100
+ (@config['options'] || []).each do |option|
101
+ case option[0].upcase
102
+ when 'QUEUE_STATE'
103
+ initial_state = option[1]
104
+ else
105
+ @logger.error("Unknown option passed to microservice #{@name}: #{option}")
106
+ end
107
+ end
108
+
109
+ @processor = QueueProcessor.new(name: @queue_name, state: initial_state, logger: @logger, scope: @scope)
110
+ @processor_thread = nil
111
+ @read_topic = true
112
+ end
113
+
114
+ def run
115
+ @logger.info "QueueMicroservice running"
116
+ @processor_thread = Thread.new { @processor.run }
117
+
118
+ # Let the frontend know that the microservice has been deployed and is running
119
+ notification = {
120
+ 'kind' => 'deployed',
121
+ # name and updated_at fields are required for Event formatting
122
+ 'data' => JSON.generate({
123
+ 'name' => @name,
124
+ 'updated_at' => Time.now.to_nsec_from_epoch,
125
+ }),
126
+ }
127
+ QueueTopic.write_notification(notification, scope: @scope)
128
+
129
+ loop do
130
+ break if @cancel_thread
131
+ block_for_updates()
132
+ end
133
+ @processor.shutdown()
134
+ @processor_thread.join() if @processor_thread
135
+ @logger.info "QueueMicroservice exiting"
136
+ end
137
+
138
+ def block_for_updates
139
+ @read_topic = true
140
+ while @read_topic && !@cancel_thread
141
+ begin
142
+ QueueTopic.read_topics(@topics) do |_topic, _msg_id, msg_hash, _redis|
143
+ data = JSON.parse(msg_hash['data']) if msg_hash['data']
144
+ if data['name'] == @queue_name and msg_hash['kind'] == 'updated'
145
+ @processor.state = data['state']
146
+ end
147
+ end
148
+ rescue StandardError => e
149
+ @logger.error "QueueMicroservice failed to read topics #{@topics}\n#{e.formatted}"
150
+ end
151
+ end
152
+ end
153
+
154
+ def shutdown
155
+ @read_topic = false
156
+ @processor.shutdown() if @processor
157
+ super
158
+ end
159
+ end
160
+ end
161
+
162
+ if __FILE__ == $0
163
+ OpenC3::QueueMicroservice.run
164
+ OpenC3::ThreadManager.instance.shutdown
165
+ OpenC3::ThreadManager.instance.join
166
+ end
@@ -14,12 +14,14 @@
14
14
  # GNU Affero General Public License for more details.
15
15
 
16
16
  # Modified by OpenC3, Inc.
17
- # All changes Copyright 2022, OpenC3, Inc.
17
+ # All changes Copyright 2025, OpenC3, Inc.
18
18
  # All Rights Reserved
19
19
  #
20
20
  # This file may also be used under the terms of a commercial license
21
21
  # if purchased from OpenC3, Inc.
22
22
 
23
+ require 'pg'
24
+ require 'thread'
23
25
  require 'openc3/utilities/store'
24
26
  require 'openc3/utilities/store_queued'
25
27
  require 'openc3/models/target_model'
@@ -28,6 +30,8 @@ module OpenC3
28
30
  class CvtModel
29
31
  @@packet_cache = {}
30
32
  @@override_cache = {}
33
+ @@conn = nil
34
+ @@conn_mutex = Mutex.new
31
35
 
32
36
  VALUE_TYPES = [:RAW, :CONVERTED, :FORMATTED, :WITH_UNITS]
33
37
  def self.build_json_from_packet(packet)
@@ -117,22 +121,149 @@ module OpenC3
117
121
  end
118
122
  end
119
123
 
124
+ def self.tsdb_lookup(items, start_time:, end_time: nil)
125
+ tables = {}
126
+ names = []
127
+ nil_count = 0
128
+ items.each do |item|
129
+ target_name, packet_name, item_name, value_type, limits = item
130
+ # They will all be nil when item is a nil value
131
+ # A nil value indicates a value that does not exist as returned by get_tlm_available
132
+ if item_name.nil?
133
+ # We know timestamp always exists so we can use it to fill in the nil value
134
+ names << "timestamp as __nil#{nil_count}"
135
+ nil_count += 1
136
+ next
137
+ end
138
+ # See https://questdb.com/docs/reference/api/ilp/advanced-settings/#name-restrictions
139
+ table_name = "#{target_name}__#{packet_name}".gsub(/[?,'"\/:\)\(\+\*\%~]/, '_')
140
+ tables[table_name] = 1
141
+ index = tables.find_index {|k,v| k == table_name }
142
+ # See https://questdb.com/docs/reference/api/ilp/advanced-settings/#name-restrictions
143
+ # NOTE: Semicolon added as it appears invalid
144
+ item_name = item_name.gsub(/[?\.,'"\\\/:\)\(\+\-\*\%~;]/, '_')
145
+ case value_type
146
+ when 'WITH_UNITS'
147
+ names << "\"T#{index}.#{item_name}__U\""
148
+ when 'FORMATTED'
149
+ names << "\"T#{index}.#{item_name}__F\""
150
+ when 'CONVERTED'
151
+ names << "\"T#{index}.#{item_name}__C\""
152
+ else
153
+ names << "\"T#{index}.#{item_name}\""
154
+ end
155
+ if limits
156
+ names << "\"T#{index}.#{item_name}__L\""
157
+ end
158
+ end
159
+
160
+ # Build the SQL query
161
+ query = "SELECT #{names.join(", ")} FROM "
162
+ tables.each_with_index do |(table_name, _), index|
163
+ if index == 0
164
+ query += "#{table_name} as T#{index} "
165
+ else
166
+ query += "ASOF JOIN #{table_name} as T#{index} "
167
+ end
168
+ end
169
+ if start_time && !end_time
170
+ query += "WHERE T0.timestamp < '#{start_time}' LIMIT -1"
171
+ elsif start_time && end_time
172
+ query += "WHERE T0.timestamp >= '#{start_time}' AND T0.timestamp < '#{end_time}'"
173
+ end
174
+
175
+ retry_count = 0
176
+ begin
177
+ @@conn_mutex.synchronize do
178
+ @@conn ||= PG::Connection.new(host: ENV['OPENC3_TSDB_HOSTNAME'],
179
+ port: ENV['OPENC3_TSDB_QUERY_PORT'],
180
+ user: ENV['OPENC3_TSDB_USERNAME'],
181
+ password: ENV['OPENC3_TSDB_PASSWORD'],
182
+ dbname: 'qdb')
183
+ # Default connection is all strings but we want to map to the correct types
184
+ if @@conn.type_map_for_results.is_a? PG::TypeMapAllStrings
185
+ # TODO: This doesn't seem to be round tripping UINT64 correctly
186
+ # Try playback with P_2.2,2 and P(:6;): from the DEMO
187
+ @@conn.type_map_for_results = PG::BasicTypeMapForResults.new @@conn
188
+ end
189
+
190
+ result = @@conn.exec(query)
191
+ if result.nil? or result.ntuples == 0
192
+ return {}
193
+ else
194
+ data = []
195
+ # Build up a results set that is an array of arrays
196
+ # Each nested array is a set of 2 items: [value, limits state]
197
+ # If the item does not have limits the limits state is nil
198
+ result.each_with_index do |tuples, index|
199
+ data[index] ||= []
200
+ row_index = 0
201
+ tuples.each do |tuple|
202
+ if tuple[0].include?("__L")
203
+ data[index][row_index - 1][1] = tuple[1]
204
+ elsif tuple[0] =~ /^__nil/
205
+ data[index][row_index] = [nil, nil]
206
+ row_index += 1
207
+ else
208
+ data[index][row_index] = [tuple[1], nil]
209
+ row_index += 1
210
+ end
211
+ end
212
+ end
213
+ # If we only have one row then we return a single array
214
+ if result.ntuples == 1
215
+ data = data[0]
216
+ end
217
+ return data
218
+ end
219
+ end
220
+ rescue IOError, PG::Error => e
221
+ # Retry the query because various errors can occur that are recoverable
222
+ retry_count += 1
223
+ if retry_count > 4
224
+ # After the 5th retry just raise the error
225
+ raise "Error querying QuestDB: #{e.message}"
226
+ end
227
+ Logger.warn("QuestDB: Retrying due to error: #{e.message}")
228
+ Logger.warn("QuestDB: Last query: #{query}") # Log the last query for debugging
229
+ @@conn_mutex.synchronize do
230
+ if @@conn and !@@conn.finished?
231
+ @@conn.finish()
232
+ end
233
+ @@conn = nil # Force the new connection
234
+ end
235
+ sleep 0.1
236
+ retry
237
+ end
238
+ end
239
+
120
240
  # Return all item values and limit state from the CVT
121
241
  #
122
242
  # @param items [Array<String>] Items to return. Must be formatted as TGT__PKT__ITEM__TYPE
123
243
  # @param stale_time [Integer] Time in seconds from Time.now that value will be marked stale
124
244
  # @return [Array] Array of values
125
- def self.get_tlm_values(items, stale_time: 30, cache_timeout: nil, scope: $openc3_scope)
245
+ def self.get_tlm_values(items, stale_time: 30, cache_timeout: nil, start_time: nil, end_time: nil, scope: $openc3_scope)
126
246
  now = Time.now
127
247
  results = []
128
248
  lookups = []
129
249
  packet_lookup = {}
130
250
  overrides = {}
251
+
252
+ # If a start_time is passed we're doing a QuestDB lookup and directly return the results
253
+ # TODO: This currently does NOT support the override values
254
+ if start_time
255
+ return tsdb_lookup(items, start_time: start_time, end_time: end_time)
256
+ end
257
+
131
258
  # First generate a lookup hash of all the items represented so we can query the CVT
132
259
  items.each { |item| _parse_item(now, lookups, overrides, item, cache_timeout: cache_timeout, scope: scope) }
133
260
 
134
261
  now = now.to_f
135
262
  lookups.each do |target_packet_key, target_name, packet_name, value_keys|
263
+ if target_packet_key.nil?
264
+ results << [nil, nil]
265
+ next
266
+ end
136
267
  unless packet_lookup[target_packet_key]
137
268
  packet_lookup[target_packet_key] = get(target_name: target_name, packet_name: packet_name, cache_timeout: cache_timeout, scope: scope)
138
269
  end
@@ -161,7 +292,8 @@ module OpenC3
161
292
  if hash.key?(value_keys[-1])
162
293
  item_result[1] = nil
163
294
  else
164
- raise "Item '#{target_name} #{packet_name} #{value_keys[-1]}' does not exist"
295
+ item_result[0] = nil
296
+ item_result[1] = nil
165
297
  end
166
298
  end
167
299
  end
@@ -335,6 +467,11 @@ module OpenC3
335
467
  # return an ordered array of hash with keys
336
468
  def self._parse_item(now, lookups, overrides, item, cache_timeout:, scope:)
337
469
  target_name, packet_name, item_name, value_type = item
470
+ # They will all be nil when item is a nil value
471
+ if item_name.nil?
472
+ lookups << nil
473
+ return
474
+ end
338
475
 
339
476
  # We build lookup keys by including all the less formatted types to gracefully degrade lookups
340
477
  # This allows the user to specify WITH_UNITS and if there is no conversions it will simply return the RAW value
@@ -55,6 +55,7 @@ module OpenC3
55
55
  attr_accessor :variables
56
56
  attr_accessor :plugin_txt_lines
57
57
  attr_accessor :needs_dependencies
58
+ attr_accessor :store_id
58
59
 
59
60
  # NOTE: The following three class methods are used by the ModelController
60
61
  # and are reimplemented to enable various Model class methods to work
@@ -72,7 +73,7 @@ module OpenC3
72
73
 
73
74
  # Called by the PluginsController to parse the plugin variables
74
75
  # Doesn't actually create the plugin during the phase
75
- def self.install_phase1(gem_file_path, existing_variables: nil, existing_plugin_txt_lines: nil, process_existing: false, scope:, validate_only: false)
76
+ 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)
76
77
  gem_name = File.basename(gem_file_path).split("__")[0]
77
78
 
78
79
  temp_dir = Dir.mktmpdir
@@ -130,7 +131,8 @@ module OpenC3
130
131
  end
131
132
  end
132
133
 
133
- model = PluginModel.new(name: gem_name, variables: variables, plugin_txt_lines: plugin_txt_lines, scope: scope)
134
+ store_id = Integer(store_id) if store_id
135
+ model = PluginModel.new(name: gem_name, variables: variables, plugin_txt_lines: plugin_txt_lines, store_id: store_id, scope: scope)
134
136
  result = model.as_json(:allow_nan => true)
135
137
  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']
136
138
  return result
@@ -299,6 +301,7 @@ module OpenC3
299
301
  variables: {},
300
302
  plugin_txt_lines: [],
301
303
  needs_dependencies: false,
304
+ store_id: nil,
302
305
  updated_at: nil,
303
306
  scope:
304
307
  )
@@ -306,6 +309,7 @@ module OpenC3
306
309
  @variables = variables
307
310
  @plugin_txt_lines = plugin_txt_lines
308
311
  @needs_dependencies = ConfigParser.handle_true_false(needs_dependencies)
312
+ @store_id = store_id
309
313
  end
310
314
 
311
315
  def create(update: false, force: false, queued: false)
@@ -319,6 +323,7 @@ module OpenC3
319
323
  'variables' => @variables,
320
324
  'plugin_txt_lines' => @plugin_txt_lines,
321
325
  'needs_dependencies' => @needs_dependencies,
326
+ 'store_id' => @store_id,
322
327
  'updated_at' => @updated_at
323
328
  }
324
329
  end
@@ -0,0 +1,70 @@
1
+ # encoding: ascii-8bit
2
+
3
+ # Copyright 2025 OpenC3, Inc.
4
+ # All Rights Reserved.
5
+ #
6
+ # This program is free software; you can modify and/or redistribute it
7
+ # under the terms of the GNU Affero General Public License
8
+ # as published by the Free Software Foundation; version 3 with
9
+ # attribution addendums as found in the LICENSE.txt
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU Affero General Public License for more details.
15
+
16
+ # This file may also be used under the terms of a commercial license
17
+ # if purchased from OpenC3, Inc.
18
+
19
+ require 'openc3/models/model'
20
+ require 'openc3/utilities/store'
21
+
22
+ module OpenC3
23
+ class PluginStoreModel < Model
24
+ PRIMARY_KEY = 'openc3_plugin_store'
25
+
26
+ def self.set(plugin_store_data)
27
+ Store.set(PRIMARY_KEY, plugin_store_data)
28
+ end
29
+
30
+ def self.all()
31
+ Store.get(PRIMARY_KEY)
32
+ end
33
+
34
+ def self.plugin_store_error(message)
35
+ Store.set(PRIMARY_KEY, [{
36
+ date: Time.now.utc.iso8601,
37
+ title: 'Plugin Store Error',
38
+ body: message,
39
+ error: true,
40
+ }].to_json)
41
+ end
42
+
43
+ def self.get_by_id(id)
44
+ plugins = JSON.parse(all()) rescue []
45
+ plugins.find { |plugin| plugin["id"] == Integer(id) }
46
+ end
47
+
48
+ def self.update
49
+ setting = SettingModel.get(name: 'store_url', scope: 'DEFAULT')
50
+ store_url = setting['data'] if setting
51
+ store_url = 'https://store.openc3.com' if store_url.nil? or store_url.strip.empty?
52
+ conn = Faraday.new(
53
+ url: store_url,
54
+ )
55
+ response = conn.get('/cosmos_plugins/json')
56
+ if response.success?
57
+ self.set(response.body)
58
+ else
59
+ self.plugin_store_error("Error contacting plugin store at #{store_url} (status: #{response.status})")
60
+ end
61
+ rescue Exception => e
62
+ self.plugin_store_error("Error contacting plugin store at #{store_url}. #{e.message})")
63
+ end
64
+
65
+ def self.ensure_exists
66
+ plugins = self.all()
67
+ self.update() if plugins.nil? or plugins.length.zero? or plugins[0]['error']
68
+ end
69
+ end
70
+ end