openc3 7.4.1 → 7.5.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.
- checksums.yaml +4 -4
- data/bin/pipinstall +18 -5
- data/bin/pipuninstall +1 -1
- data/bin/uvinstall +36 -8
- data/data/config/default_plugins.yaml +43 -0
- data/data/config/interface_modifiers.yaml +22 -7
- data/data/config/item_modifiers.yaml +14 -4
- data/data/config/plugins.yaml +1 -1
- data/data/config/widgets.yaml +1 -1
- data/lib/openc3/api/cmd_api.rb +10 -13
- data/lib/openc3/api/interface_api.rb +1 -2
- data/lib/openc3/api/limits_api.rb +1 -1
- data/lib/openc3/api/metrics_api.rb +26 -1
- data/lib/openc3/api/router_api.rb +1 -2
- data/lib/openc3/api/tlm_api.rb +6 -10
- data/lib/openc3/config/config_parser.rb +3 -1
- data/lib/openc3/core_ext/tempfile.rb +2 -0
- data/lib/openc3/interfaces/file_interface.rb +6 -1
- data/lib/openc3/interfaces/http_client_interface.rb +14 -6
- data/lib/openc3/interfaces/interface.rb +27 -3
- data/lib/openc3/interfaces/read_queue_interface.rb +331 -0
- data/lib/openc3/interfaces/stream_interface.rb +22 -3
- data/lib/openc3/interfaces/tcpip_server_interface.rb +13 -0
- data/lib/openc3/interfaces/udp_interface.rb +16 -2
- data/lib/openc3/interfaces.rb +1 -0
- data/lib/openc3/logs/log_writer.rb +13 -4
- data/lib/openc3/microservices/decom_microservice.rb +7 -4
- data/lib/openc3/microservices/interface_microservice.rb +146 -50
- data/lib/openc3/microservices/log_microservice.rb +1 -3
- data/lib/openc3/microservices/microservice.rb +25 -1
- data/lib/openc3/microservices/plugin_microservice.rb +32 -0
- data/lib/openc3/microservices/text_log_microservice.rb +1 -3
- data/lib/openc3/models/interface_model.rb +1 -1
- data/lib/openc3/models/interface_status_model.rb +6 -0
- data/lib/openc3/models/metric_model.rb +52 -4
- data/lib/openc3/models/microservice_model.rb +7 -3
- data/lib/openc3/models/plugin_model.rb +12 -12
- data/lib/openc3/models/python_package_model.rb +3 -13
- data/lib/openc3/models/target_model.rb +83 -9
- data/lib/openc3/operators/microservice_operator.rb +5 -0
- data/lib/openc3/operators/operator.rb +69 -0
- data/lib/openc3/packets/packet.rb +10 -4
- data/lib/openc3/packets/packet_config.rb +12 -1
- data/lib/openc3/packets/packet_item.rb +27 -14
- data/lib/openc3/packets/parsers/state_parser.rb +1 -1
- data/lib/openc3/packets/structure_item.rb +3 -2
- data/lib/openc3/script/api_shared.rb +58 -84
- data/lib/openc3/script/extract.rb +215 -19
- data/lib/openc3/script/plugins.rb +4 -11
- data/lib/openc3/script/storage.rb +81 -10
- data/lib/openc3/system/system.rb +9 -6
- data/lib/openc3/topics/command_decom_topic.rb +1 -1
- data/lib/openc3/topics/command_topic.rb +2 -1
- data/lib/openc3/topics/router_topic.rb +2 -1
- data/lib/openc3/topics/telemetry_decom_topic.rb +1 -1
- data/lib/openc3/topics/telemetry_topic.rb +3 -2
- data/lib/openc3/topics/topic.rb +154 -2
- data/lib/openc3/utilities/cli_generator.rb +67 -15
- data/lib/openc3/utilities/config_overlay.rb +35 -17
- data/lib/openc3/utilities/default_plugins.rb +33 -0
- data/lib/openc3/utilities/local_mode.rb +82 -46
- data/lib/openc3/utilities/metric.rb +16 -0
- data/lib/openc3/utilities/process_stats.rb +160 -0
- data/lib/openc3/utilities/pypi_url.rb +65 -0
- data/lib/openc3/utilities/questdb_client.rb +14 -0
- data/lib/openc3/utilities/running_script.rb +22 -18
- data/lib/openc3/utilities/store_autoload.rb +20 -3
- data/lib/openc3/utilities/store_queued.rb +37 -9
- data/lib/openc3/utilities/target_file.rb +57 -12
- data/lib/openc3/utilities/topic_lag_monitor.rb +193 -0
- data/lib/openc3/version.rb +6 -6
- data/templates/conversion/conversion.py +6 -2
- data/templates/conversion/conversion.rb +5 -0
- data/templates/plugin/.github/workflows/playwright.yml +62 -0
- data/templates/plugin/.github/workflows/release.yml +45 -0
- data/templates/plugin/.github/workflows/unit-tests.yml +35 -0
- data/templates/plugin/.gitignore +6 -0
- data/templates/plugin/README.md +114 -2
- data/templates/plugin/lib/PACKAGE/__init__.py +4 -0
- data/templates/plugin/plugin.gemspec +10 -1
- data/templates/plugin/pyproject.toml +119 -0
- data/templates/tool_angular/package.json +2 -2
- data/templates/tool_angular/pnpm-workspace.yaml +16 -0
- data/templates/tool_react/package.json +1 -1
- data/templates/tool_react/pnpm-workspace.yaml +16 -0
- data/templates/tool_svelte/package.json +2 -1
- data/templates/tool_svelte/pnpm-workspace.yaml +20 -0
- data/templates/tool_vue/package.json +3 -3
- data/templates/tool_vue/pnpm-workspace.yaml +16 -0
- data/templates/widget/package.json +2 -2
- data/templates/widget/pnpm-workspace.yaml +16 -0
- metadata +16 -1
data/lib/openc3/system/system.rb
CHANGED
|
@@ -78,7 +78,8 @@ module OpenC3
|
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
# target_version can also be the actual hash used in the target_archives folder
|
|
81
|
-
|
|
81
|
+
# @param descriptions [Boolean] Whether to keep packet and item descriptions
|
|
82
|
+
def self.setup_targets(target_names, base_dir, target_version: 'current', scope:, descriptions: true)
|
|
82
83
|
# Nothing to do if there are no targets
|
|
83
84
|
return if target_names.nil? or target_names.length == 0
|
|
84
85
|
if @@instance.nil?
|
|
@@ -118,7 +119,7 @@ module OpenC3
|
|
|
118
119
|
end
|
|
119
120
|
|
|
120
121
|
# Build System from targets
|
|
121
|
-
System.instance(target_names, targets_path)
|
|
122
|
+
System.instance(target_names, targets_path, descriptions: descriptions)
|
|
122
123
|
end
|
|
123
124
|
end
|
|
124
125
|
|
|
@@ -137,14 +138,15 @@ module OpenC3
|
|
|
137
138
|
#
|
|
138
139
|
# @param target_names [Array of target_names]
|
|
139
140
|
# @param target_config_dir Directory where target config folders are
|
|
141
|
+
# @param descriptions [Boolean] Whether to keep packet and item descriptions
|
|
140
142
|
# @return [System] The System singleton
|
|
141
|
-
def self.instance(target_names = nil, target_config_dir = nil)
|
|
143
|
+
def self.instance(target_names = nil, target_config_dir = nil, descriptions: true)
|
|
142
144
|
return @@instance if @@instance
|
|
143
145
|
raise "System.instance parameters are required on first call" unless target_names and target_config_dir
|
|
144
146
|
|
|
145
147
|
@@instance_mutex.synchronize do
|
|
146
148
|
return @@instance if @@instance
|
|
147
|
-
@@instance ||= self.new(target_names, target_config_dir)
|
|
149
|
+
@@instance ||= self.new(target_names, target_config_dir, descriptions: descriptions)
|
|
148
150
|
@@post_instance_callbacks.each do |callback|
|
|
149
151
|
callback.call
|
|
150
152
|
end
|
|
@@ -171,10 +173,11 @@ module OpenC3
|
|
|
171
173
|
#
|
|
172
174
|
# @param target_names [Array of target names]
|
|
173
175
|
# @param target_config_dir Directory where target config folders are
|
|
174
|
-
|
|
176
|
+
# @param descriptions [Boolean] Whether to keep packet and item descriptions
|
|
177
|
+
def initialize(target_names, target_config_dir, descriptions: true)
|
|
175
178
|
OpenC3.add_to_search_path(target_config_dir, true) if target_config_dir
|
|
176
179
|
@targets = {}
|
|
177
|
-
@packet_config = PacketConfig.new
|
|
180
|
+
@packet_config = PacketConfig.new(descriptions: descriptions)
|
|
178
181
|
@commands = Commands.new(@packet_config)
|
|
179
182
|
@telemetry = Telemetry.new(@packet_config)
|
|
180
183
|
@limits = Limits.new(@packet_config)
|
|
@@ -58,7 +58,7 @@ module OpenC3
|
|
|
58
58
|
msg_hash['json_data'] = JSON.generate(json_hash.as_json, allow_nan: true)
|
|
59
59
|
msg_hash['extra'] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
|
|
60
60
|
db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
|
|
61
|
-
EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash)
|
|
61
|
+
EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash, minid: Topic.stream_safety_minid)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def self.get_cmd_item(target_name, packet_name, param_name, type: :FORMATTED, scope: $openc3_scope)
|
|
@@ -35,7 +35,8 @@ module OpenC3
|
|
|
35
35
|
buffer: packet.buffer(false) }
|
|
36
36
|
msg_hash[:extra] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
|
|
37
37
|
db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
|
|
38
|
-
|
|
38
|
+
minid = Topic.log_stream_safety_minid(packet.target_name, :CMD, scope: scope)
|
|
39
|
+
EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash, minid: minid)
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
# @param command [Hash] Command hash structure read to be written to a topic
|
|
@@ -84,7 +84,8 @@ module OpenC3
|
|
|
84
84
|
Topic.write_topic(topic, { 'target_name' => packet.target_name, 'cmd_name' => packet.packet_name, 'cmd_buffer' => packet.buffer(false) }, '*', 100)
|
|
85
85
|
elsif target_names.length == 1
|
|
86
86
|
topic = "{#{scope}__CMD}TARGET__#{target_names[0]}"
|
|
87
|
-
|
|
87
|
+
# Name the routed target so the interface identifies against it (and sends it as UNKNOWN if that fails)
|
|
88
|
+
Topic.write_topic(topic, { 'target_name' => target_names[0], 'cmd_name' => 'UNKNOWN', 'cmd_buffer' => packet.buffer(false) }, '*', 100)
|
|
88
89
|
else
|
|
89
90
|
raise "No route for command: #{packet.target_name ? packet.target_name : 'UNKNOWN'} #{packet.packet_name ? packet.packet_name : 'UNKNOWN'}"
|
|
90
91
|
end
|
|
@@ -45,7 +45,7 @@ module OpenC3
|
|
|
45
45
|
}
|
|
46
46
|
msg_hash[:extra] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
|
|
47
47
|
db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
|
|
48
|
-
Topic.write_topic("#{scope}__DECOM__{#{packet.target_name}}__#{packet.packet_name}", msg_hash, id, db_shard: db_shard)
|
|
48
|
+
Topic.write_topic("#{scope}__DECOM__{#{packet.target_name}}__#{packet.packet_name}", msg_hash, id, minid: Topic.stream_safety_minid(id), db_shard: db_shard)
|
|
49
49
|
|
|
50
50
|
unless packet.stored
|
|
51
51
|
# Also update the current value table with the latest decommutated data
|
|
@@ -33,10 +33,11 @@ module OpenC3
|
|
|
33
33
|
msg_hash[:extra] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
|
|
34
34
|
topic = "#{scope}__TELEMETRY__{#{packet.target_name}}__#{packet.packet_name}"
|
|
35
35
|
db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
|
|
36
|
+
minid = Topic.log_stream_safety_minid(packet.target_name, :TLM, scope: scope)
|
|
36
37
|
if queued
|
|
37
|
-
EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash)
|
|
38
|
+
EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash, minid: minid)
|
|
38
39
|
else
|
|
39
|
-
Topic.write_topic(topic, msg_hash, db_shard: db_shard)
|
|
40
|
+
Topic.write_topic(topic, msg_hash, minid: minid, db_shard: db_shard)
|
|
40
41
|
end
|
|
41
42
|
end
|
|
42
43
|
end
|
data/lib/openc3/topics/topic.rb
CHANGED
|
@@ -16,9 +16,48 @@
|
|
|
16
16
|
# if purchased from OpenC3, Inc.
|
|
17
17
|
|
|
18
18
|
require 'openc3/utilities/store'
|
|
19
|
+
require 'openc3/utilities/logger'
|
|
19
20
|
|
|
20
21
|
module OpenC3
|
|
21
22
|
class Topic
|
|
23
|
+
# Stream trimming contract for the high rate target streams
|
|
24
|
+
# (TELEMETRY__, COMMAND__, DECOM__, DECOMCMD__):
|
|
25
|
+
#
|
|
26
|
+
# The streams are primarily trimmed by their consumers once the data is safely
|
|
27
|
+
# persisted elsewhere:
|
|
28
|
+
# * TELEMETRY__ / COMMAND__ are trimmed by the log microservice (LogWriter) once a
|
|
29
|
+
# log file has been moved to the bucket plus LogWriter::CLEANUP_DELAY.
|
|
30
|
+
# * DECOM__ / DECOMCMD__ are trimmed by the TSDB microservice which keeps
|
|
31
|
+
# TsdbMicroservice::TRIM_KEEP_MS of data.
|
|
32
|
+
#
|
|
33
|
+
# If a trimmer is absent (logging disabled, no TSDB) or falls far behind, the streams
|
|
34
|
+
# would grow without bound. As a safety net every write to these streams also passes
|
|
35
|
+
# an approximate XADD MINID so no entry older than the safety max age is retained.
|
|
36
|
+
# The max age is OPENC3_STREAM_MAX_AGE_SECONDS (default 600s, 0 or empty disables) and is
|
|
37
|
+
# never less than STREAM_MAX_AGE_MIN_SECONDS. For TELEMETRY__ / COMMAND__ the max age is
|
|
38
|
+
# never less than two log cycles plus the cleanup delay, because historical streaming reads
|
|
39
|
+
# data not yet in the bucket from the stream.
|
|
40
|
+
#
|
|
41
|
+
# When the minid is calculated from now (auto generated ids) it uses this host's clock while
|
|
42
|
+
# Redis generates the ids from its own clock. A writer clock ahead of Redis by more than the
|
|
43
|
+
# max age would trim the entire stream (including the entry being added) so hosts must be
|
|
44
|
+
# time synchronized (NTP).
|
|
45
|
+
STREAM_MAX_AGE_DEFAULT_SECONDS = 600
|
|
46
|
+
# Twice TsdbMicroservice::TRIM_KEEP_MS. Also protects against small clock offsets.
|
|
47
|
+
STREAM_MAX_AGE_MIN_SECONDS = 120
|
|
48
|
+
# LogWriter::CLEANUP_DELAY is defined from this
|
|
49
|
+
LOG_CLEANUP_DELAY_SECONDS = 60
|
|
50
|
+
# TargetModel default log cycle time
|
|
51
|
+
DEFAULT_LOG_CYCLE_TIME_SECONDS = 600
|
|
52
|
+
# How long to wait before retrying a failed log cycle time lookup
|
|
53
|
+
LOG_CYCLE_LOOKUP_RETRY_SECONDS = 60
|
|
54
|
+
# These are calculated on the write path of every packet so they are cached per process.
|
|
55
|
+
# The environment doesn't change while a microservice runs and the log cycle time only
|
|
56
|
+
# changes when the plugin (and therefore the microservices) are reinstalled.
|
|
57
|
+
@@stream_max_age_ms = nil
|
|
58
|
+
@@log_stream_max_age_ms = {}
|
|
59
|
+
@@log_cycle_lookup_retry_at = {}
|
|
60
|
+
|
|
22
61
|
# Delegate all unknown class methods to EphemeralStore db_shard 0 (system-level topics)
|
|
23
62
|
def self.method_missing(message, *args, **kwargs, &block)
|
|
24
63
|
EphemeralStore.public_send(message, *args, **kwargs, &block)
|
|
@@ -37,8 +76,121 @@ module OpenC3
|
|
|
37
76
|
# DB_Shard-aware topic methods for target-specific streams.
|
|
38
77
|
# These explicitly route to the correct EphemeralStore db_shard.
|
|
39
78
|
|
|
40
|
-
def self.write_topic(topic, msg_hash, id = '*', maxlen = nil, approximate = 'true', db_shard: 0)
|
|
41
|
-
EphemeralStore.instance(db_shard: db_shard).write_topic(topic, msg_hash, id, maxlen, approximate)
|
|
79
|
+
def self.write_topic(topic, msg_hash, id = '*', maxlen = nil, approximate = 'true', minid: nil, db_shard: 0)
|
|
80
|
+
EphemeralStore.instance(db_shard: db_shard).write_topic(topic, msg_hash, id, maxlen, approximate, minid: minid)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Float] Configured safety max age in seconds (<= 0 means disabled)
|
|
84
|
+
def self.stream_max_age_seconds
|
|
85
|
+
value = ENV.fetch('OPENC3_STREAM_MAX_AGE_SECONDS', nil)
|
|
86
|
+
return STREAM_MAX_AGE_DEFAULT_SECONDS if value.nil?
|
|
87
|
+
return 0.0 if value.strip.empty?
|
|
88
|
+
# Strictly parse and reject nan / inf, which would raise when converted to
|
|
89
|
+
# milliseconds on every write. A typo keeps the safety cap at the default
|
|
90
|
+
# rather than disabling it.
|
|
91
|
+
seconds = Float(value, exception: false)
|
|
92
|
+
return seconds if seconds&.finite?
|
|
93
|
+
Logger.warn("OPENC3_STREAM_MAX_AGE_SECONDS of '#{value}' is invalid, using #{STREAM_MAX_AGE_DEFAULT_SECONDS}")
|
|
94
|
+
return STREAM_MAX_AGE_DEFAULT_SECONDS
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Integer] Cached safety max age in milliseconds (0 means disabled)
|
|
98
|
+
def self.stream_max_age_ms
|
|
99
|
+
@@stream_max_age_ms ||= begin
|
|
100
|
+
seconds = stream_max_age_seconds
|
|
101
|
+
if seconds <= 0
|
|
102
|
+
0
|
|
103
|
+
elsif seconds < STREAM_MAX_AGE_MIN_SECONDS
|
|
104
|
+
Logger.warn("OPENC3_STREAM_MAX_AGE_SECONDS of #{seconds} is below the minimum, using #{STREAM_MAX_AGE_MIN_SECONDS}")
|
|
105
|
+
STREAM_MAX_AGE_MIN_SECONDS * 1000
|
|
106
|
+
else
|
|
107
|
+
(seconds * 1000).to_i
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Calculates the MINID to pass to XADD to cap the age of entries in a target stream.
|
|
113
|
+
# The cap is anchored to the id being written (or now for auto generated ids) so an
|
|
114
|
+
# explicitly id'd entry (e.g. decom reusing the raw packet id) is never trimmed by its own add.
|
|
115
|
+
#
|
|
116
|
+
# @param id [String] The id being written ('*' or nil for auto generation)
|
|
117
|
+
# @param max_age_ms [Integer] Max age to retain (<= 0 means disabled)
|
|
118
|
+
# @return [String|nil] The minid or nil if the safety cap is disabled
|
|
119
|
+
def self.stream_safety_minid(id = '*', max_age_ms: stream_max_age_ms)
|
|
120
|
+
return nil if max_age_ms <= 0
|
|
121
|
+
if id.nil? or id == '*'
|
|
122
|
+
base_ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
|
|
123
|
+
else
|
|
124
|
+
base_ms = id.to_s.to_i # Stops at the '-' sequence separator
|
|
125
|
+
end
|
|
126
|
+
minid_ms = base_ms - max_age_ms
|
|
127
|
+
return nil if minid_ms <= 0
|
|
128
|
+
return minid_ms.to_s
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Calculates the MINID for a TELEMETRY__ / COMMAND__ stream write which also never
|
|
132
|
+
# trims younger than log_stream_min_age_seconds. The resulting max age is cached per stream.
|
|
133
|
+
#
|
|
134
|
+
# @param target_name [String] Target name
|
|
135
|
+
# @param cmd_or_tlm [Symbol] :CMD or :TLM
|
|
136
|
+
# @return [String|nil] The minid or nil if the safety cap is disabled
|
|
137
|
+
def self.log_stream_safety_minid(target_name, cmd_or_tlm, scope:)
|
|
138
|
+
target_cache = ((@@log_stream_max_age_ms[scope] ||= {})[target_name] ||= {})
|
|
139
|
+
max_age_ms = target_cache[cmd_or_tlm]
|
|
140
|
+
unless max_age_ms
|
|
141
|
+
max_age_ms = log_stream_max_age_ms(target_name, cmd_or_tlm, scope: scope)
|
|
142
|
+
return nil unless max_age_ms # Log cycle time unknown so don't cap yet
|
|
143
|
+
target_cache[cmd_or_tlm] = max_age_ms
|
|
144
|
+
end
|
|
145
|
+
return stream_safety_minid(max_age_ms: max_age_ms)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @param target_name [String] Target name
|
|
149
|
+
# @param cmd_or_tlm [Symbol] :CMD or :TLM
|
|
150
|
+
# @return [Integer|nil] Max age in milliseconds for a TELEMETRY__ / COMMAND__ stream (0 means
|
|
151
|
+
# disabled) or nil if the log cycle time couldn't be determined. Guessing the log cycle time
|
|
152
|
+
# could trim data historical streaming still needs, so the stream isn't capped until a
|
|
153
|
+
# lookup (retried every LOG_CYCLE_LOOKUP_RETRY_SECONDS) succeeds.
|
|
154
|
+
def self.log_stream_max_age_ms(target_name, cmd_or_tlm, scope:)
|
|
155
|
+
max_age_ms = stream_max_age_ms
|
|
156
|
+
return max_age_ms if max_age_ms <= 0
|
|
157
|
+
key = [scope, target_name, cmd_or_tlm]
|
|
158
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
159
|
+
retry_at = @@log_cycle_lookup_retry_at[key]
|
|
160
|
+
return nil if retry_at and now < retry_at
|
|
161
|
+
begin
|
|
162
|
+
min_age_ms = log_stream_min_age_seconds(target_name, cmd_or_tlm, scope: scope) * 1000
|
|
163
|
+
rescue => e
|
|
164
|
+
Logger.warn("Unable to determine log cycle time for #{target_name} #{cmd_or_tlm}, stream age not capped: #{e.message}")
|
|
165
|
+
@@log_cycle_lookup_retry_at[key] = now + LOG_CYCLE_LOOKUP_RETRY_SECONDS
|
|
166
|
+
return nil
|
|
167
|
+
end
|
|
168
|
+
@@log_cycle_lookup_retry_at.delete(key)
|
|
169
|
+
return [max_age_ms, min_age_ms].max
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Minimum age to retain in a TELEMETRY__ / COMMAND__ stream so the log microservice
|
|
173
|
+
# and historical streaming always have the data not yet moved to the bucket.
|
|
174
|
+
#
|
|
175
|
+
# @param target_name [String] Target name
|
|
176
|
+
# @param cmd_or_tlm [Symbol] :CMD or :TLM
|
|
177
|
+
# @return [Integer] Minimum age in seconds
|
|
178
|
+
# @raise If the TargetModel lookup fails
|
|
179
|
+
def self.log_stream_min_age_seconds(target_name, cmd_or_tlm, scope:)
|
|
180
|
+
require 'openc3/models/target_model'
|
|
181
|
+
cycle_time = DEFAULT_LOG_CYCLE_TIME_SECONDS
|
|
182
|
+
model = TargetModel.get(name: target_name, scope: scope)
|
|
183
|
+
if model
|
|
184
|
+
value = model[cmd_or_tlm == :CMD ? 'cmd_log_cycle_time' : 'tlm_log_cycle_time'].to_i
|
|
185
|
+
cycle_time = value if value > 0
|
|
186
|
+
end
|
|
187
|
+
return 2 * (cycle_time + LOG_CLEANUP_DELAY_SECONDS)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def self.clear_stream_max_age_cache
|
|
191
|
+
@@stream_max_age_ms = nil
|
|
192
|
+
@@log_stream_max_age_ms = {}
|
|
193
|
+
@@log_cycle_lookup_retry_at = {}
|
|
42
194
|
end
|
|
43
195
|
|
|
44
196
|
def self.read_topics(topics, offsets = nil, timeout_ms = 1000, count = nil, db_shard: 0, &block)
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
# This file may also be used under the terms of a commercial license
|
|
12
12
|
# if purchased from OpenC3, Inc.
|
|
13
13
|
|
|
14
|
+
require 'open3'
|
|
15
|
+
|
|
14
16
|
module OpenC3
|
|
15
17
|
class CliGenerator
|
|
16
18
|
GENERATORS = %w(plugin target microservice widget conversion processor limits_response tool tool_vue tool_angular tool_react tool_svelte command_validator)
|
|
@@ -392,9 +394,26 @@ module OpenC3
|
|
|
392
394
|
end
|
|
393
395
|
FileUtils.mkdir(plugin_name)
|
|
394
396
|
Dir.chdir(plugin_name) # Change to the plugin path to make copying easier
|
|
397
|
+
# Every installed plugin's lib/ goes on sys.path, so python code shared
|
|
398
|
+
# across the plugin lives in a package named after the plugin rather than
|
|
399
|
+
# directly in lib/, where a same-named module in another plugin collides.
|
|
400
|
+
package_name = plugin_name.gsub(/[^a-z0-9]+/, '_')
|
|
395
401
|
|
|
396
402
|
process_template("#{TEMPLATES_DIR}/plugin", binding) do |filename|
|
|
397
403
|
filename.sub!("plugin.gemspec", "#{plugin_name}.gemspec")
|
|
404
|
+
filename.sub!("lib/PACKAGE", "lib/#{package_name}")
|
|
405
|
+
# Only python plugins get a pyproject.toml. Shipping one in every plugin
|
|
406
|
+
# would make install_phase2 take the python dependency path (and build a
|
|
407
|
+
# per-plugin venv) for plugins that have no python code at all.
|
|
408
|
+
#
|
|
409
|
+
# lib/ comes with it because pyproject.toml sets ty's extra-paths to
|
|
410
|
+
# lib/, and ty exits non-zero rather than warning when a configured path
|
|
411
|
+
# does not exist. The package's __init__.py does reach the gem, which is
|
|
412
|
+
# harmless: the gem's pyproject.toml already sets needs_dependencies.
|
|
413
|
+
if @@language != 'py'
|
|
414
|
+
next true if filename == 'pyproject.toml'
|
|
415
|
+
next true if filename == 'lib' or filename.start_with?('lib/')
|
|
416
|
+
end
|
|
398
417
|
false
|
|
399
418
|
end
|
|
400
419
|
|
|
@@ -409,10 +428,28 @@ module OpenC3
|
|
|
409
428
|
end
|
|
410
429
|
end
|
|
411
430
|
|
|
431
|
+
write_python_version if @@language == 'py'
|
|
432
|
+
|
|
412
433
|
puts "Plugin #{plugin_name} successfully generated!"
|
|
413
434
|
return plugin_name
|
|
414
435
|
end
|
|
415
436
|
|
|
437
|
+
# Pin the plugin's local uv environment to the Python minor version COSMOS
|
|
438
|
+
# runs, read from the interpreter itself rather than hardcoded in the
|
|
439
|
+
# template: it differs between images (Debian vs UBI) and moves with the base
|
|
440
|
+
# image. `cli generate` runs in the cmd-tlm-api image, which sets
|
|
441
|
+
# OPENC3_PYTHON_BIN. Outside an image (e.g. the gem installed on a dev
|
|
442
|
+
# machine) there is no COSMOS Python to match, so no file is written.
|
|
443
|
+
def self.write_python_version
|
|
444
|
+
python_bin = ENV.fetch('OPENC3_PYTHON_BIN', nil)
|
|
445
|
+
return if python_bin.nil? || !File.executable?(python_bin)
|
|
446
|
+
version, status = Open3.capture2(python_bin, '-c', 'import sys; print("%d.%d" % sys.version_info[:2])')
|
|
447
|
+
return unless status.success? && version.strip =~ /\A\d+\.\d+\z/
|
|
448
|
+
File.write('.python-version', "#{version.strip}\n")
|
|
449
|
+
rescue SystemCallError
|
|
450
|
+
nil
|
|
451
|
+
end
|
|
452
|
+
|
|
416
453
|
def self.generate_target(args)
|
|
417
454
|
validate_language_inheriting_args!('target', args,
|
|
418
455
|
example: ['cli generate target EXAMPLE --ruby', 'Creates: targets/EXAMPLE/'],
|
|
@@ -447,18 +484,33 @@ module OpenC3
|
|
|
447
484
|
end
|
|
448
485
|
gemspec_filename = Dir['*.gemspec'][0]
|
|
449
486
|
gemspec = File.read(gemspec_filename)
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
#
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
487
|
+
# Older COSMOS versions wrote a singular python_dep_file block that never
|
|
488
|
+
# ships uv.lock, and a second python target nested another copy of it.
|
|
489
|
+
# Strip every copy plus its use on the s.files line so the rewrite below
|
|
490
|
+
# replaces it. A hand-edited block that no longer matches leaves some
|
|
491
|
+
# python_dep_file reference behind, so the gemspec is kept as it was.
|
|
492
|
+
legacy_block = /^ *# Prefer pyproject\.toml over requirements\.txt\n *python_dep_file = if File\.exist\?\('pyproject\.toml'\)\n *'pyproject\.toml'\n *else\n *'requirements\.txt'\n *end\n/
|
|
493
|
+
upgraded = gemspec.gsub(legacy_block, '').sub(' + [python_dep_file]', '')
|
|
494
|
+
gemspec = upgraded unless upgraded.include?('python_dep_file')
|
|
495
|
+
# Skip when a previous python target already rewrote s.files: the line it
|
|
496
|
+
# wrote matches the pattern below too, so rewriting again would nest a
|
|
497
|
+
# second dependency block. python_dep_file also covers a legacy block
|
|
498
|
+
# the upgrade above left alone. Also skip a gemspec from the
|
|
499
|
+
# python plugin template, which already ships pyproject.toml and uv.lock.
|
|
500
|
+
unless gemspec.include?('python_dep_file') || gemspec.include?('pyproject.toml')
|
|
501
|
+
gemspec.gsub!(/^ *s\.files = Dir\.glob.*\n/) do
|
|
502
|
+
<<~RUBY.gsub(/^/, ' ')
|
|
503
|
+
# Prefer pyproject.toml (plus uv.lock if committed) over requirements.txt
|
|
504
|
+
python_dep_files = if File.exist?('pyproject.toml')
|
|
505
|
+
['pyproject.toml'] + Dir.glob('uv.lock')
|
|
506
|
+
else
|
|
507
|
+
['requirements.txt']
|
|
508
|
+
end
|
|
509
|
+
s.files = Dir.glob("{targets,lib,public,tools,microservices}/**/*") + %w(Rakefile README.md LICENSE.md plugin.txt) + python_dep_files
|
|
510
|
+
RUBY
|
|
511
|
+
end
|
|
512
|
+
File.write(gemspec_filename, gemspec)
|
|
460
513
|
end
|
|
461
|
-
File.write(gemspec_filename, gemspec)
|
|
462
514
|
|
|
463
515
|
target_txt_filename = "targets/#{target_name}/target.txt"
|
|
464
516
|
target_txt = File.read(target_txt_filename)
|
|
@@ -626,8 +678,8 @@ RUBY
|
|
|
626
678
|
arguments: common_args,
|
|
627
679
|
notes: common_note,
|
|
628
680
|
example: [
|
|
629
|
-
"cli generate #{args[0]} '
|
|
630
|
-
"Creates: tools/
|
|
681
|
+
"cli generate #{args[0]} 'Mission Planner'",
|
|
682
|
+
"Creates: tools/missionplanner/ (#{framework}-based)",
|
|
631
683
|
],
|
|
632
684
|
in_plugin_extra: 'For other tool types, see: cli generate tool --help',
|
|
633
685
|
docs: docs_url,
|
|
@@ -650,8 +702,8 @@ RUBY
|
|
|
650
702
|
],
|
|
651
703
|
},
|
|
652
704
|
example: [
|
|
653
|
-
"cli generate tool '
|
|
654
|
-
'Creates: tools/
|
|
705
|
+
"cli generate tool 'Mission Planner'",
|
|
706
|
+
'Creates: tools/missionplanner/',
|
|
655
707
|
],
|
|
656
708
|
docs: docs_url,
|
|
657
709
|
exit_code: (args[1].nil? ? 1 : 0),
|
|
@@ -15,24 +15,27 @@ module OpenC3
|
|
|
15
15
|
# Shared predicates describing which parts of the user-writable config overlay
|
|
16
16
|
# (targets_modified) a non-admin may write.
|
|
17
17
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# decom microservices.
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
18
|
+
# Two target subdirectories hold definitions whose GENERIC_READ_CONVERSION /
|
|
19
|
+
# GENERIC_WRITE_CONVERSION blocks are evaluated as code:
|
|
20
|
+
# - <TARGET>/cmd_tlm/...: loaded by System.setup_targets and processed by
|
|
21
|
+
# PacketConfig in the decom microservices. Only the server-side
|
|
22
|
+
# dynamic-packet mechanism (TargetModel#dynamic_update) writes that area
|
|
23
|
+
# without going through an API request.
|
|
24
|
+
# - <TARGET>/tables/config/...: table definitions, processed by TableConfig
|
|
25
|
+
# in cmd-tlm-api for every Table Manager operation.
|
|
26
|
+
# Writing either is therefore an admin operation, matching the tier COSMOS
|
|
27
|
+
# requires everywhere else code is introduced (plugin install).
|
|
25
28
|
#
|
|
26
29
|
# Every API writer that can reach the overlay must consult these predicates:
|
|
27
30
|
# - storage_controller#get_upload_presigned_request / delete (presigned S3 upload)
|
|
28
|
-
# - tables_controller#save / save_as /
|
|
31
|
+
# - tables_controller#save / save_as / report / destroy (Table -> TargetFile)
|
|
29
32
|
# - scripts_controller#create / destroy (Script -> TargetFile)
|
|
30
33
|
module ConfigOverlay
|
|
31
34
|
# Config bucket areas a non-admin is allowed to write at all
|
|
32
35
|
NON_ADMIN_AREAS = ['targets_modified', 'tmp'].freeze
|
|
33
36
|
|
|
34
|
-
# Target
|
|
35
|
-
|
|
37
|
+
# Target subdirectories whose contents are executed as code
|
|
38
|
+
CODE_AREAS = [['cmd_tlm'], ['tables', 'config']].freeze
|
|
36
39
|
|
|
37
40
|
# Split a path into segments, or nil if the path is not canonical.
|
|
38
41
|
#
|
|
@@ -48,14 +51,23 @@ module OpenC3
|
|
|
48
51
|
parts
|
|
49
52
|
end
|
|
50
53
|
|
|
51
|
-
# True if the overlay-relative name targets
|
|
52
|
-
# it requires admin. Name is relative to
|
|
53
|
-
# "<TARGET>/cmd_tlm/tlm.txt". Fails closed (true)
|
|
54
|
-
|
|
54
|
+
# True if the overlay-relative name targets a code area (cmd_tlm or
|
|
55
|
+
# tables/config), i.e. writing it requires admin. Name is relative to
|
|
56
|
+
# targets_modified/, e.g. "<TARGET>/cmd_tlm/tlm.txt". Fails closed (true)
|
|
57
|
+
# on non-canonical names.
|
|
58
|
+
def self.code_overlay?(name)
|
|
55
59
|
parts = canonical_parts(name)
|
|
56
60
|
return true if parts.nil?
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
code_area?(parts)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# True if the target-relative name is a canonical table definition path,
|
|
65
|
+
# e.g. "<TARGET>/tables/config/table_def.txt". Table only parses definitions
|
|
66
|
+
# from here, so a definition can't be pointed at a non-admin overlay area.
|
|
67
|
+
def self.table_definition?(name)
|
|
68
|
+
parts = canonical_parts(name)
|
|
69
|
+
return false if parts.nil?
|
|
70
|
+
parts.length > 3 and parts[1, 2] == ['tables', 'config']
|
|
59
71
|
end
|
|
60
72
|
|
|
61
73
|
# True if the given config bucket key is an overlay path a non-admin may
|
|
@@ -68,8 +80,14 @@ module OpenC3
|
|
|
68
80
|
# parts: <SCOPE> / <area> / <TARGET> / <subdir> / ...
|
|
69
81
|
area = parts[1]
|
|
70
82
|
return false unless NON_ADMIN_AREAS.include?(area)
|
|
71
|
-
return false if area == 'targets_modified' && parts[
|
|
83
|
+
return false if area == 'targets_modified' && code_area?(parts[2..])
|
|
72
84
|
true
|
|
73
85
|
end
|
|
86
|
+
|
|
87
|
+
# target_parts: <TARGET> / <subdir> / ...
|
|
88
|
+
def self.code_area?(target_parts)
|
|
89
|
+
CODE_AREAS.any? { |area| target_parts[1, area.length] == area }
|
|
90
|
+
end
|
|
91
|
+
private_class_method :code_area?
|
|
74
92
|
end
|
|
75
93
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
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 'yaml'
|
|
15
|
+
|
|
16
|
+
module OpenC3
|
|
17
|
+
# The plugins that ship with COSMOS. The list lives in
|
|
18
|
+
# data/config/default_plugins.yaml and is shared with the Admin Plugins tab
|
|
19
|
+
# and the script API via the 'default' flag from GET /openc3-api/plugins/all
|
|
20
|
+
module DefaultPlugins
|
|
21
|
+
# Short names, i.e. without the version or the __ suffix
|
|
22
|
+
NAMES = YAML.safe_load_file(
|
|
23
|
+
File.expand_path('../../../data/config/default_plugins.yaml', __dir__)
|
|
24
|
+
).freeze
|
|
25
|
+
|
|
26
|
+
# True if the plugin (e.g. "openc3-cosmos-tool-admin-6.0.0.gem__20260101000000")
|
|
27
|
+
# is one that ships with COSMOS
|
|
28
|
+
def self.default?(plugin_name)
|
|
29
|
+
# Strip the __ suffix and the trailing -version.gem to get the short name
|
|
30
|
+
NAMES.include?(plugin_name.split('__')[0].split('-')[0..-2].join('-'))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|