openc3 7.0.1 → 7.1.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +50 -3
  3. data/data/config/interface_modifiers.yaml +3 -1
  4. data/data/config/item_modifiers.yaml +1 -1
  5. data/data/config/microservice.yaml +15 -2
  6. data/data/config/parameter_modifiers.yaml +49 -7
  7. data/data/config/plugins.yaml +1 -0
  8. data/data/config/target.yaml +11 -0
  9. data/data/config/target_config.yaml +6 -2
  10. data/lib/openc3/api/api.rb +1 -0
  11. data/lib/openc3/api/calendar_api.rb +183 -0
  12. data/lib/openc3/api/cmd_api.rb +2 -1
  13. data/lib/openc3/api/metrics_api.rb +11 -1
  14. data/lib/openc3/api/tlm_api.rb +21 -6
  15. data/lib/openc3/core_ext/faraday.rb +1 -1
  16. data/lib/openc3/io/json_api.rb +1 -1
  17. data/lib/openc3/logs/log_writer.rb +3 -1
  18. data/lib/openc3/microservices/decom_common.rb +128 -0
  19. data/lib/openc3/microservices/decom_microservice.rb +30 -97
  20. data/lib/openc3/microservices/interface_decom_common.rb +6 -2
  21. data/lib/openc3/microservices/interface_microservice.rb +10 -8
  22. data/lib/openc3/microservices/log_microservice.rb +1 -1
  23. data/lib/openc3/microservices/microservice.rb +3 -2
  24. data/lib/openc3/microservices/queue_microservice.rb +1 -1
  25. data/lib/openc3/microservices/scope_cleanup_microservice.rb +60 -46
  26. data/lib/openc3/microservices/text_log_microservice.rb +1 -2
  27. data/lib/openc3/models/cvt_model.rb +24 -13
  28. data/lib/openc3/models/db_sharded_model.rb +110 -0
  29. data/lib/openc3/models/interface_model.rb +9 -0
  30. data/lib/openc3/models/interface_status_model.rb +33 -3
  31. data/lib/openc3/models/metric_model.rb +96 -37
  32. data/lib/openc3/models/microservice_model.rb +7 -0
  33. data/lib/openc3/models/microservice_status_model.rb +30 -3
  34. data/lib/openc3/models/plugin_model.rb +20 -8
  35. data/lib/openc3/models/queue_model.rb +36 -46
  36. data/lib/openc3/models/reingest_job_model.rb +153 -0
  37. data/lib/openc3/models/scope_model.rb +3 -2
  38. data/lib/openc3/models/script_status_model.rb +4 -20
  39. data/lib/openc3/models/target_model.rb +113 -100
  40. data/lib/openc3/models/trigger_model.rb +1 -1
  41. data/lib/openc3/packets/packet_config.rb +4 -1
  42. data/lib/openc3/packets/parsers/xtce_parser.rb +23 -1
  43. data/lib/openc3/script/script.rb +6 -4
  44. data/lib/openc3/script/script_runner.rb +4 -4
  45. data/lib/openc3/script/telemetry.rb +3 -3
  46. data/lib/openc3/script/web_socket_api.rb +29 -22
  47. data/lib/openc3/system/system.rb +20 -3
  48. data/lib/openc3/topics/command_decom_topic.rb +4 -2
  49. data/lib/openc3/topics/command_topic.rb +9 -5
  50. data/lib/openc3/topics/decom_interface_topic.rb +15 -10
  51. data/lib/openc3/topics/interface_topic.rb +71 -29
  52. data/lib/openc3/topics/limits_event_topic.rb +62 -41
  53. data/lib/openc3/topics/router_topic.rb +61 -21
  54. data/lib/openc3/topics/system_events_topic.rb +18 -1
  55. data/lib/openc3/topics/telemetry_decom_topic.rb +3 -1
  56. data/lib/openc3/topics/telemetry_topic.rb +4 -2
  57. data/lib/openc3/topics/topic.rb +77 -5
  58. data/lib/openc3/utilities/aws_bucket.rb +2 -0
  59. data/lib/openc3/utilities/cli_generator.rb +10 -2
  60. data/lib/openc3/utilities/metric.rb +15 -1
  61. data/lib/openc3/utilities/questdb_client.rb +173 -37
  62. data/lib/openc3/utilities/reingest_job.rb +377 -0
  63. data/lib/openc3/utilities/ruby_lex_utils.rb +2 -0
  64. data/lib/openc3/utilities/running_script.rb +8 -10
  65. data/lib/openc3/utilities/store_autoload.rb +78 -52
  66. data/lib/openc3/utilities/store_queued.rb +20 -12
  67. data/lib/openc3/version.rb +5 -5
  68. data/templates/microservice/microservices/TEMPLATE/microservice.py +9 -0
  69. data/templates/plugin/plugin.gemspec +13 -1
  70. data/templates/tool_angular/package.json +2 -2
  71. data/templates/tool_react/package.json +1 -1
  72. data/templates/tool_svelte/package.json +1 -1
  73. data/templates/tool_vue/package.json +3 -3
  74. data/templates/tool_vue/src/router.js +2 -2
  75. data/templates/widget/package.json +2 -2
  76. metadata +8 -3
@@ -158,12 +158,12 @@ module OpenC3
158
158
  variables[current_variable_name]['description'] = params[0]
159
159
  when 'VARIABLE_STATE'
160
160
  usage = "#{keyword} <Display Text> <Value>"
161
- parser.verify_num_parameters(2, 2, usage)
161
+ parser.verify_num_parameters(1, 2, usage)
162
162
  unless current_variable_name
163
163
  raise "VARIABLE_STATE must follow a VARIABLE definition"
164
164
  end
165
165
  variables[current_variable_name]['options'] ||= []
166
- option = { 'value' => params[1], 'text' => params[0] }
166
+ option = { 'value' => params[1] || params[0], 'text' => params[0] }
167
167
  variables[current_variable_name]['options'] << option
168
168
  end
169
169
  end
@@ -229,12 +229,13 @@ module OpenC3
229
229
  plugin_model.homepage = pkg.spec.homepage
230
230
  plugin_model.repository = pkg.spec.metadata['source_code_uri'] # this key because it's in the official gemspec examples
231
231
  plugin_model.keywords = pkg.spec.metadata['openc3_store_keywords']&.split(/, ?/)
232
- img_path = pkg.spec.metadata['openc3_store_image']
233
- unless img_path
234
- default_img_path = 'public/store_img.png'
235
- full_default_path = File.join(gem_path, default_img_path)
236
- img_path = default_img_path if File.exist? full_default_path
237
- end
232
+ # Resolve the plugin's store image path. The gemspec generated by
233
+ # `cli generate plugin` sets `openc3_store_image` to `public/store_img.png`
234
+ # but does not create that file, so we must verify existence regardless
235
+ # of whether the path came from metadata or the default — otherwise the
236
+ # frontend hits a 500 when fetching the missing file.
237
+ img_path = pkg.spec.metadata['openc3_store_image'] || 'public/store_img.png'
238
+ img_path = nil unless File.exist?(File.join(gem_path, img_path))
238
239
  package_name = "#{pkg.spec.name}-#{pkg.spec.version}"
239
240
  plugin_model.img_path = File.join('gems', package_name, img_path) if img_path # convert this filesystem path to volumes mount path
240
241
  plugin_model.update() unless validate_only
@@ -525,5 +526,16 @@ module OpenC3
525
526
  end
526
527
  return result.sort
527
528
  end
529
+
530
+ # Remove the backing gem for an unloaded plugin so it disappears from
531
+ # GemModel.names (and the admin Packages tab). Skips the removal if any
532
+ # other PluginModel (any scope, any counter) still references the gem,
533
+ # via the existing PluginModel.gem_names check inside GemModel.destroy.
534
+ def self.cleanup_gem(plugin_name, scope:)
535
+ gem_filename = plugin_name.split("__")[0]
536
+ OpenC3::GemModel.destroy(gem_filename, log_and_raise_needed_errors: false)
537
+ rescue => e
538
+ Logger.warn("Could not remove gem #{gem_filename}: #{e.message}", scope: scope)
539
+ end
528
540
  end
529
541
  end
@@ -44,34 +44,37 @@ module OpenC3
44
44
  model = get_model(name: name, scope: scope)
45
45
  raise QueueError, "Queue '#{name}' not found in scope '#{scope}'" unless model
46
46
 
47
- if model.state != 'DISABLE'
48
- result = Store.zrevrange("#{scope}:#{name}", 0, 0, with_scores: true)
49
- if result.empty?
50
- id = 1.0
51
- else
52
- id = result[0][1].to_f + 1
53
- end
54
-
55
- # Build command data with support for both formats
56
- command_data = { username: username, timestamp: Time.now.to_nsec_from_epoch, validate: validate, timeout: timeout }
57
- if target_name && cmd_name
58
- # New format: store target_name, cmd_name, and cmd_params separately
59
- command_data[:target_name] = target_name
60
- command_data[:cmd_name] = cmd_name
61
- command_data[:cmd_params] = JSON.generate(cmd_params.as_json, allow_nan: true) if cmd_params
62
- elsif command
63
- # Legacy format: store command string for backwards compatibility
64
- command_data[:value] = command
65
- else
66
- raise QueueError, "Must provide either command string or target_name/cmd_name parameters"
67
- end
68
-
69
- Store.zadd("#{scope}:#{name}", id, command_data.to_json)
70
- model.notify(kind: 'command')
71
- else
47
+ if model.state == 'DISABLE'
72
48
  error_msg = command || "#{target_name} #{cmd_name}"
73
49
  raise QueueError, "Queue '#{name}' is disabled. Command '#{error_msg}' not queued."
74
50
  end
51
+
52
+ result = Store.zrevrange("#{scope}:#{name}", 0, 0, with_scores: true)
53
+ id = result.empty? ? 1.0 : result[0][1].to_f + 1
54
+
55
+ command_data = build_command_data(username: username, command: command, target_name: target_name,
56
+ cmd_name: cmd_name, cmd_params: cmd_params, validate: validate, timeout: timeout)
57
+ Store.zadd("#{scope}:#{name}", id, command_data.to_json)
58
+ model.notify(kind: 'command')
59
+ end
60
+
61
+ # Build the hash that gets serialized into Redis. Always uses symbol keys so
62
+ # downstream code in this class can access values consistently. cmd_params is
63
+ # JSON-encoded as a string so binary data survives the round-trip via as_json.
64
+ def self.build_command_data(username:, command: nil, target_name: nil, cmd_name: nil, cmd_params: nil, validate: nil, timeout: nil)
65
+ command_data = { username: username, timestamp: Time.now.to_nsec_from_epoch }
66
+ if target_name && cmd_name
67
+ command_data[:target_name] = target_name
68
+ command_data[:cmd_name] = cmd_name
69
+ command_data[:cmd_params] = JSON.generate(cmd_params.as_json, allow_nan: true) if cmd_params
70
+ elsif command
71
+ command_data[:value] = command
72
+ else
73
+ raise QueueError, "Must provide either command string or target_name/cmd_name parameters"
74
+ end
75
+ command_data[:validate] = validate unless validate.nil?
76
+ command_data[:timeout] = timeout unless timeout.nil?
77
+ command_data
75
78
  end
76
79
 
77
80
  attr_accessor :name, :state
@@ -115,49 +118,36 @@ module OpenC3
115
118
  QueueTopic.write_notification(notification, scope: @scope)
116
119
  end
117
120
 
118
- def insert_command(id, command_data)
121
+ def insert_command(id: nil, username:, command: nil, target_name: nil, cmd_name: nil, cmd_params: nil, validate: nil, timeout: nil)
119
122
  if @state == 'DISABLE'
120
- if command_data['value']
121
- command_name = command_data['value']
122
- else
123
- command_name = "#{command_data['target_name']} #{command_data['cmd_name']}"
124
- end
123
+ command_name = command || "#{target_name} #{cmd_name}"
125
124
  raise QueueError, "Queue '#{@name}' is disabled. Command '#{command_name}' not queued."
126
125
  end
127
126
 
128
127
  unless id
129
128
  result = Store.zrevrange("#{@scope}:#{@name}", 0, 0, with_scores: true)
130
- if result.empty?
131
- id = 1.0
132
- else
133
- id = result[0][1].to_f + 1
134
- end
129
+ id = result.empty? ? 1.0 : result[0][1].to_f + 1
135
130
  end
136
131
 
137
- # Convert cmd_params values to JSON-safe format if present
138
- if command_data['cmd_params']
139
- command_data['cmd_params'] = JSON.generate(command_data['cmd_params'].as_json, allow_nan: true)
140
- end
132
+ command_data = self.class.build_command_data(username: username, command: command, target_name: target_name,
133
+ cmd_name: cmd_name, cmd_params: cmd_params, validate: validate, timeout: timeout)
141
134
  Store.zadd("#{@scope}:#{@name}", id, command_data.to_json)
142
135
  notify(kind: 'command')
143
136
  end
144
137
 
145
- def update_command(id:, command:, username:, validate: nil, timeout: nil)
138
+ def update_command(id:, username:, command: nil, target_name: nil, cmd_name: nil, cmd_params: nil, validate: nil, timeout: nil)
146
139
  if @state == 'DISABLE'
147
140
  raise QueueError, "Queue '#{@name}' is disabled. Command at id #{id} not updated."
148
141
  end
149
142
 
150
- # Check if command exists at the given id
151
143
  existing = Store.zrangebyscore("#{@scope}:#{@name}", id, id)
152
144
  if existing.empty?
153
145
  raise QueueError, "No command found at id #{id} in queue '#{@name}'"
154
146
  end
155
147
 
156
- # Remove the existing command and add the new one at the same id
157
148
  Store.zremrangebyscore("#{@scope}:#{@name}", id, id)
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?
149
+ command_data = self.class.build_command_data(username: username, command: command, target_name: target_name,
150
+ cmd_name: cmd_name, cmd_params: cmd_params, validate: validate, timeout: timeout)
161
151
  Store.zadd("#{@scope}:#{@name}", id, command_data.to_json)
162
152
  notify(kind: 'command')
163
153
  end
@@ -0,0 +1,153 @@
1
+ # encoding: ascii-8bit
2
+
3
+ # Copyright 2026 OpenC3, Inc.
4
+ # All Rights Reserved.
5
+ #
6
+ # This program is distributed in the hope that it will be useful,
7
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
8
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
9
+ # See LICENSE.md for more details.
10
+ #
11
+ # This file may also be used under the terms of a commercial license
12
+ # if purchased from OpenC3, Inc.
13
+
14
+ require 'openc3/models/model'
15
+
16
+ module OpenC3
17
+ # Tracks one run of OpenC3::ReingestJob. The job updates this record from a
18
+ # background thread; the storage_controller status endpoint reads it.
19
+ # `updated_at` doubles as the heartbeat — if a Running record hasn't been
20
+ # touched in STALE_THRESHOLD_SEC, the status endpoint surfaces it as 'Stale'.
21
+ class ReingestJobModel < Model
22
+ PRIMARY_KEY = 'openc3_reingest_job'
23
+ STALE_THRESHOLD_SEC = 60
24
+
25
+ STATES = %w[Queued Running Complete Crashed Stale].freeze
26
+ PHASES = %w[downloading enabling_dedup ingesting dedup_cooldown disabling_dedup].freeze
27
+
28
+ attr_accessor :state
29
+ attr_accessor :files
30
+ attr_accessor :bucket
31
+ attr_accessor :path
32
+ attr_accessor :table_names
33
+ attr_accessor :target_version
34
+ attr_accessor :versions_used
35
+ attr_accessor :warnings
36
+ attr_accessor :progress_phase
37
+ attr_accessor :progress_current
38
+ attr_accessor :progress_total
39
+ attr_accessor :packets_written
40
+ attr_accessor :dedup_enabled_by_us
41
+ attr_accessor :dedup_preexisting
42
+ attr_accessor :dedup_disabled_tables
43
+ attr_accessor :dedup_cooldown_seconds
44
+ attr_accessor :dedup_enabled_at
45
+ attr_accessor :dedup_disabled_at
46
+ attr_accessor :error
47
+ attr_accessor :started_at
48
+ attr_accessor :finished_at
49
+
50
+ def self.get(name:, scope:)
51
+ super("#{scope}__#{PRIMARY_KEY}", name: name)
52
+ end
53
+
54
+ def self.names(scope:)
55
+ super("#{scope}__#{PRIMARY_KEY}")
56
+ end
57
+
58
+ def self.all(scope:)
59
+ all = super("#{scope}__#{PRIMARY_KEY}")
60
+ all.sort_by { |_key, value| value['updated_at'] }.reverse
61
+ end
62
+
63
+ def initialize(
64
+ name:,
65
+ state: 'Queued',
66
+ files: [],
67
+ bucket: nil,
68
+ path: nil,
69
+ table_names: [],
70
+ target_version: 'as_logged',
71
+ versions_used: [],
72
+ warnings: [],
73
+ progress_phase: nil,
74
+ progress_current: 0,
75
+ progress_total: 0,
76
+ packets_written: 0,
77
+ dedup_enabled_by_us: [],
78
+ dedup_preexisting: [],
79
+ dedup_disabled_tables: [],
80
+ dedup_cooldown_seconds: 60,
81
+ dedup_enabled_at: nil,
82
+ dedup_disabled_at: nil,
83
+ error: nil,
84
+ started_at: nil,
85
+ finished_at: nil,
86
+ updated_at: nil,
87
+ plugin: nil,
88
+ scope:
89
+ )
90
+ super("#{scope}__#{PRIMARY_KEY}", name: name, updated_at: updated_at, plugin: plugin, scope: scope)
91
+ @state = state
92
+ @files = files
93
+ @bucket = bucket
94
+ @path = path
95
+ @table_names = table_names
96
+ @target_version = target_version
97
+ @versions_used = versions_used
98
+ @warnings = warnings
99
+ @progress_phase = progress_phase
100
+ @progress_current = progress_current
101
+ @progress_total = progress_total
102
+ @packets_written = packets_written
103
+ @dedup_enabled_by_us = dedup_enabled_by_us
104
+ @dedup_preexisting = dedup_preexisting
105
+ @dedup_disabled_tables = dedup_disabled_tables
106
+ @dedup_cooldown_seconds = dedup_cooldown_seconds
107
+ @dedup_enabled_at = dedup_enabled_at
108
+ @dedup_disabled_at = dedup_disabled_at
109
+ @error = error
110
+ @started_at = started_at
111
+ @finished_at = finished_at
112
+ end
113
+
114
+ # True if state is Running but the heartbeat (updated_at) is older than
115
+ # STALE_THRESHOLD_SEC. Callers should surface state as 'Stale' in that case.
116
+ def stale?
117
+ return false unless @state == 'Running'
118
+ return false unless @updated_at
119
+ age_nsec = Time.now.to_nsec_from_epoch - @updated_at.to_i
120
+ age_nsec > STALE_THRESHOLD_SEC * 1_000_000_000
121
+ end
122
+
123
+ def as_json(*_a)
124
+ {
125
+ 'name' => @name,
126
+ 'state' => stale? ? 'Stale' : @state,
127
+ 'files' => @files,
128
+ 'bucket' => @bucket,
129
+ 'path' => @path,
130
+ 'table_names' => @table_names,
131
+ 'target_version' => @target_version,
132
+ 'versions_used' => @versions_used,
133
+ 'warnings' => @warnings,
134
+ 'progress_phase' => @progress_phase,
135
+ 'progress_current' => @progress_current,
136
+ 'progress_total' => @progress_total,
137
+ 'packets_written' => @packets_written,
138
+ 'dedup_enabled_by_us' => @dedup_enabled_by_us,
139
+ 'dedup_preexisting' => @dedup_preexisting,
140
+ 'dedup_disabled_tables' => @dedup_disabled_tables,
141
+ 'dedup_cooldown_seconds' => @dedup_cooldown_seconds,
142
+ 'dedup_enabled_at' => @dedup_enabled_at,
143
+ 'dedup_disabled_at' => @dedup_disabled_at,
144
+ 'error' => @error,
145
+ 'started_at' => @started_at,
146
+ 'finished_at' => @finished_at,
147
+ 'updated_at' => @updated_at,
148
+ 'plugin' => @plugin,
149
+ 'scope' => @scope,
150
+ }
151
+ end
152
+ end
153
+ end
@@ -389,8 +389,9 @@ module OpenC3
389
389
  end
390
390
 
391
391
  # Delete the topics we created for the scope
392
- Topic.del("#{@scope}__COMMAND__{UNKNOWN}__UNKNOWN")
393
- Topic.del("#{@scope}__TELEMETRY__{UNKNOWN}__UNKNOWN")
392
+ db_shard = Store.db_shard_for_target('UNKNOWN', scope: @scope)
393
+ Topic.del("#{@scope}__COMMAND__{UNKNOWN}__UNKNOWN", db_shard: db_shard)
394
+ Topic.del("#{@scope}__TELEMETRY__{UNKNOWN}__UNKNOWN", db_shard: db_shard)
394
395
  Topic.del("#{@scope}__openc3_targets")
395
396
  Topic.del("#{@scope}__CONFIG")
396
397
  end
@@ -66,17 +66,9 @@ module OpenC3
66
66
  keys = self.store.zrevrange("#{RUNNING_PRIMARY_KEY}__#{scope}__LIST", offset.to_i, offset.to_i + limit.to_i - 1)
67
67
  return [] if keys.empty?
68
68
  result = []
69
- if $openc3_redis_cluster
70
- # No pipelining for cluster mode
71
- # because it requires using the same shard for all keys
69
+ result = self.store.redis_pool.pipelined do
72
70
  keys.each do |key|
73
- result << self.store.hget("#{RUNNING_PRIMARY_KEY}__#{scope}", key)
74
- end
75
- else
76
- result = self.store.redis_pool.pipelined do
77
- keys.each do |key|
78
- self.store.hget("#{RUNNING_PRIMARY_KEY}__#{scope}", key)
79
- end
71
+ self.store.hget("#{RUNNING_PRIMARY_KEY}__#{scope}", key)
80
72
  end
81
73
  end
82
74
  result = result.map do |r|
@@ -91,17 +83,9 @@ module OpenC3
91
83
  keys = self.store.zrevrange("#{COMPLETED_PRIMARY_KEY}__#{scope}__LIST", offset.to_i, offset.to_i + limit.to_i - 1)
92
84
  return [] if keys.empty?
93
85
  result = []
94
- if $openc3_redis_cluster
95
- # No pipelining for cluster mode
96
- # because it requires using the same shard for all keys
86
+ result = self.store.redis_pool.pipelined do
97
87
  keys.each do |key|
98
- result << self.store.hget("#{COMPLETED_PRIMARY_KEY}__#{scope}", key)
99
- end
100
- else
101
- result = self.store.redis_pool.pipelined do
102
- keys.each do |key|
103
- self.store.hget("#{COMPLETED_PRIMARY_KEY}__#{scope}", key)
104
- end
88
+ self.store.hget("#{COMPLETED_PRIMARY_KEY}__#{scope}", key)
105
89
  end
106
90
  end
107
91
  result = result.map do |r|