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.
- checksums.yaml +4 -4
- data/bin/openc3cli +58 -10
- data/bin/pipinstall +38 -6
- data/data/config/command_modifiers.yaml +1 -0
- data/data/config/interface_modifiers.yaml +1 -1
- data/data/config/item_modifiers.yaml +20 -7
- data/data/config/table_parameter_modifiers.yaml +3 -1
- data/data/config/telemetry.yaml +1 -1
- data/lib/openc3/accessors/json_accessor.rb +1 -1
- data/lib/openc3/accessors/template_accessor.rb +9 -0
- data/lib/openc3/api/tlm_api.rb +3 -3
- data/lib/openc3/config/config_parser.rb +4 -4
- data/lib/openc3/conversions/conversion.rb +3 -3
- data/lib/openc3/core_ext/faraday.rb +4 -0
- data/lib/openc3/interfaces/interface.rb +1 -6
- data/lib/openc3/logs/log_writer.rb +24 -6
- data/lib/openc3/logs/packet_log_writer.rb +1 -4
- data/lib/openc3/logs/stream_log_pair.rb +11 -4
- data/lib/openc3/logs/text_log_writer.rb +1 -4
- data/lib/openc3/microservices/decom_microservice.rb +1 -1
- data/lib/openc3/microservices/interface_decom_common.rb +22 -8
- data/lib/openc3/microservices/interface_microservice.rb +14 -3
- data/lib/openc3/microservices/log_microservice.rb +7 -2
- data/lib/openc3/microservices/microservice.rb +10 -4
- data/lib/openc3/microservices/queue_microservice.rb +3 -0
- data/lib/openc3/microservices/scope_cleanup_microservice.rb +116 -1
- data/lib/openc3/microservices/text_log_microservice.rb +4 -1
- data/lib/openc3/migrations/20260204000000_remove_decom_reducer.rb +2 -0
- data/lib/openc3/models/activity_model.rb +15 -3
- data/lib/openc3/models/cvt_model.rb +2 -247
- data/lib/openc3/models/plugin_model.rb +9 -1
- data/lib/openc3/models/plugin_store_model.rb +1 -1
- data/lib/openc3/models/python_package_model.rb +1 -1
- data/lib/openc3/models/reaction_model.rb +27 -9
- data/lib/openc3/models/script_engine_model.rb +1 -1
- data/lib/openc3/models/target_model.rb +32 -34
- data/lib/openc3/models/tool_model.rb +18 -5
- data/lib/openc3/models/trigger_model.rb +25 -8
- data/lib/openc3/models/widget_model.rb +1 -2
- data/lib/openc3/operators/operator.rb +9 -7
- data/lib/openc3/packets/json_packet.rb +2 -0
- data/lib/openc3/packets/packet.rb +1 -0
- data/lib/openc3/packets/packet_config.rb +28 -12
- data/lib/openc3/script/api_shared.rb +39 -2
- data/lib/openc3/script/calendar.rb +40 -10
- data/lib/openc3/script/extract.rb +46 -13
- data/lib/openc3/script/script.rb +19 -0
- data/lib/openc3/script/storage.rb +6 -6
- data/lib/openc3/system/system.rb +6 -6
- data/lib/openc3/tools/cmd_tlm_server/interface_thread.rb +0 -2
- data/lib/openc3/top_level.rb +15 -63
- data/lib/openc3/topics/decom_interface_topic.rb +19 -4
- data/lib/openc3/topics/interface_topic.rb +21 -2
- data/lib/openc3/topics/limits_event_topic.rb +1 -1
- data/lib/openc3/utilities/bucket_utilities.rb +3 -1
- data/lib/openc3/utilities/cli_generator.rb +7 -0
- data/lib/openc3/utilities/cmd_log.rb +1 -1
- data/lib/openc3/utilities/ctrf.rb +231 -0
- data/lib/openc3/utilities/local_mode.rb +3 -0
- data/lib/openc3/utilities/process_manager.rb +1 -1
- data/lib/openc3/utilities/python_proxy.rb +11 -4
- data/lib/openc3/utilities/questdb_client.rb +739 -22
- data/lib/openc3/utilities/running_script.rb +25 -7
- data/lib/openc3/utilities/script.rb +452 -0
- data/lib/openc3/utilities/secrets.rb +1 -1
- data/lib/openc3/version.rb +6 -6
- data/templates/conversion/conversion.py +0 -8
- data/templates/conversion/conversion.rb +0 -11
- data/templates/tool_angular/package.json +2 -2
- data/templates/tool_react/package.json +1 -1
- data/templates/tool_svelte/package.json +1 -1
- data/templates/tool_vue/package.json +3 -4
- data/templates/widget/package.json +2 -2
- metadata +17 -2
- 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
|
-
|
|
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 |
|
|
129
|
-
plw_hash.each do |
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 = [
|
|
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
|