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.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/bin/pipinstall +18 -5
  3. data/bin/pipuninstall +1 -1
  4. data/bin/uvinstall +36 -8
  5. data/data/config/default_plugins.yaml +43 -0
  6. data/data/config/interface_modifiers.yaml +22 -7
  7. data/data/config/item_modifiers.yaml +14 -4
  8. data/data/config/plugins.yaml +1 -1
  9. data/data/config/widgets.yaml +1 -1
  10. data/lib/openc3/api/cmd_api.rb +10 -13
  11. data/lib/openc3/api/interface_api.rb +1 -2
  12. data/lib/openc3/api/limits_api.rb +1 -1
  13. data/lib/openc3/api/metrics_api.rb +26 -1
  14. data/lib/openc3/api/router_api.rb +1 -2
  15. data/lib/openc3/api/tlm_api.rb +6 -10
  16. data/lib/openc3/config/config_parser.rb +3 -1
  17. data/lib/openc3/core_ext/tempfile.rb +2 -0
  18. data/lib/openc3/interfaces/file_interface.rb +6 -1
  19. data/lib/openc3/interfaces/http_client_interface.rb +14 -6
  20. data/lib/openc3/interfaces/interface.rb +27 -3
  21. data/lib/openc3/interfaces/read_queue_interface.rb +331 -0
  22. data/lib/openc3/interfaces/stream_interface.rb +22 -3
  23. data/lib/openc3/interfaces/tcpip_server_interface.rb +13 -0
  24. data/lib/openc3/interfaces/udp_interface.rb +16 -2
  25. data/lib/openc3/interfaces.rb +1 -0
  26. data/lib/openc3/logs/log_writer.rb +13 -4
  27. data/lib/openc3/microservices/decom_microservice.rb +7 -4
  28. data/lib/openc3/microservices/interface_microservice.rb +146 -50
  29. data/lib/openc3/microservices/log_microservice.rb +1 -3
  30. data/lib/openc3/microservices/microservice.rb +25 -1
  31. data/lib/openc3/microservices/plugin_microservice.rb +32 -0
  32. data/lib/openc3/microservices/text_log_microservice.rb +1 -3
  33. data/lib/openc3/models/interface_model.rb +1 -1
  34. data/lib/openc3/models/interface_status_model.rb +6 -0
  35. data/lib/openc3/models/metric_model.rb +52 -4
  36. data/lib/openc3/models/microservice_model.rb +7 -3
  37. data/lib/openc3/models/plugin_model.rb +12 -12
  38. data/lib/openc3/models/python_package_model.rb +3 -13
  39. data/lib/openc3/models/target_model.rb +83 -9
  40. data/lib/openc3/operators/microservice_operator.rb +5 -0
  41. data/lib/openc3/operators/operator.rb +69 -0
  42. data/lib/openc3/packets/packet.rb +10 -4
  43. data/lib/openc3/packets/packet_config.rb +12 -1
  44. data/lib/openc3/packets/packet_item.rb +27 -14
  45. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  46. data/lib/openc3/packets/structure_item.rb +3 -2
  47. data/lib/openc3/script/api_shared.rb +58 -84
  48. data/lib/openc3/script/extract.rb +215 -19
  49. data/lib/openc3/script/plugins.rb +4 -11
  50. data/lib/openc3/script/storage.rb +81 -10
  51. data/lib/openc3/system/system.rb +9 -6
  52. data/lib/openc3/topics/command_decom_topic.rb +1 -1
  53. data/lib/openc3/topics/command_topic.rb +2 -1
  54. data/lib/openc3/topics/router_topic.rb +2 -1
  55. data/lib/openc3/topics/telemetry_decom_topic.rb +1 -1
  56. data/lib/openc3/topics/telemetry_topic.rb +3 -2
  57. data/lib/openc3/topics/topic.rb +154 -2
  58. data/lib/openc3/utilities/cli_generator.rb +67 -15
  59. data/lib/openc3/utilities/config_overlay.rb +35 -17
  60. data/lib/openc3/utilities/default_plugins.rb +33 -0
  61. data/lib/openc3/utilities/local_mode.rb +82 -46
  62. data/lib/openc3/utilities/metric.rb +16 -0
  63. data/lib/openc3/utilities/process_stats.rb +160 -0
  64. data/lib/openc3/utilities/pypi_url.rb +65 -0
  65. data/lib/openc3/utilities/questdb_client.rb +14 -0
  66. data/lib/openc3/utilities/running_script.rb +22 -18
  67. data/lib/openc3/utilities/store_autoload.rb +20 -3
  68. data/lib/openc3/utilities/store_queued.rb +37 -9
  69. data/lib/openc3/utilities/target_file.rb +57 -12
  70. data/lib/openc3/utilities/topic_lag_monitor.rb +193 -0
  71. data/lib/openc3/version.rb +6 -6
  72. data/templates/conversion/conversion.py +6 -2
  73. data/templates/conversion/conversion.rb +5 -0
  74. data/templates/plugin/.github/workflows/playwright.yml +62 -0
  75. data/templates/plugin/.github/workflows/release.yml +45 -0
  76. data/templates/plugin/.github/workflows/unit-tests.yml +35 -0
  77. data/templates/plugin/.gitignore +6 -0
  78. data/templates/plugin/README.md +114 -2
  79. data/templates/plugin/lib/PACKAGE/__init__.py +4 -0
  80. data/templates/plugin/plugin.gemspec +10 -1
  81. data/templates/plugin/pyproject.toml +119 -0
  82. data/templates/tool_angular/package.json +2 -2
  83. data/templates/tool_angular/pnpm-workspace.yaml +16 -0
  84. data/templates/tool_react/package.json +1 -1
  85. data/templates/tool_react/pnpm-workspace.yaml +16 -0
  86. data/templates/tool_svelte/package.json +2 -1
  87. data/templates/tool_svelte/pnpm-workspace.yaml +20 -0
  88. data/templates/tool_vue/package.json +3 -3
  89. data/templates/tool_vue/pnpm-workspace.yaml +16 -0
  90. data/templates/widget/package.json +2 -2
  91. data/templates/widget/pnpm-workspace.yaml +16 -0
  92. metadata +16 -1
@@ -24,20 +24,32 @@ module OpenC3
24
24
  # Mutex used to ensure that only one instance is created
25
25
  @@instance_mutex = Mutex.new
26
26
 
27
+ DEFAULT_UPDATE_INTERVAL = 1 # seconds
28
+
27
29
  # Get the singleton instance for the given db_shard
28
- # Sets the update interval to 1 second by default
29
- def self.instance(update_interval = 1, db_shard: 0) # seconds
30
+ # Uses the interval from set_update_interval_all_shards if not given
31
+ def self.instance(update_interval = nil, db_shard: 0)
30
32
  @instances ||= []
31
33
  the_instance = @instances[db_shard]
32
34
  return the_instance if the_instance
33
35
 
34
36
  @@instance_mutex.synchronize do
35
37
  @instances ||= []
36
- @instances[db_shard] ||= self.new(update_interval, db_shard: db_shard)
38
+ @instances[db_shard] ||= self.new(update_interval || @update_interval || DEFAULT_UPDATE_INTERVAL, db_shard: db_shard)
37
39
  return @instances[db_shard]
38
40
  end
39
41
  end
40
42
 
43
+ # Set the update interval on every db_shard instance, including ones created later
44
+ def self.set_update_interval_all_shards(interval)
45
+ return unless interval > 0.0
46
+
47
+ @@instance_mutex.synchronize do
48
+ @update_interval = interval
49
+ (@instances || []).compact.each { |the_instance| the_instance.set_update_interval(interval) }
50
+ end
51
+ end
52
+
41
53
  # Delegate all unknown class methods to delegate to the instance (db_shard 0)
42
54
  def self.method_missing(message, *args, **kwargs, &)
43
55
  self.instance.public_send(message, *args, **kwargs, &)
@@ -49,6 +61,8 @@ module OpenC3
49
61
  @store = store_instance()
50
62
  # Queue to hold the store requests
51
63
  @store_queue = Queue.new
64
+ # Orders queueing against the final drain in shutdown
65
+ @shutdown_mutex = Mutex.new
52
66
  # Sleeper used to delay update thread
53
67
  @update_sleeper = Sleeper.new
54
68
 
@@ -102,11 +116,18 @@ module OpenC3
102
116
  @update_sleeper.cancel if @update_sleeper
103
117
  OpenC3.kill_thread(self, @update_thread) if @update_thread
104
118
  @update_thread = nil
105
- # Drain the queue before shutdown
106
- begin
107
- process_queue()
108
- rescue
109
- # Best effort - Redis may already be unavailable during shutdown
119
+ # Nothing drains the queue once the thread is gone, so from here on write
120
+ # straight through rather than silently dropping whatever gets queued.
121
+ # Drain first and flip the flag last, under the mutex, so a direct write
122
+ # can't be overwritten by an older queued one and nothing is queued after
123
+ # the drain.
124
+ @shutdown_mutex.synchronize do
125
+ begin
126
+ process_queue()
127
+ rescue
128
+ # Best effort - Redis may already be unavailable during shutdown
129
+ end
130
+ @shutdown = true
110
131
  end
111
132
  end
112
133
 
@@ -114,7 +135,14 @@ module OpenC3
114
135
 
115
136
  # Record the message for pipelining by the thread
116
137
  def method_missing(message, *args, **kwargs, &block)
117
- @store_queue.push(MessageStruct.new(message, args, kwargs, block))
138
+ # @shutdown never goes back to false, so once set the lock can be skipped
139
+ unless @shutdown
140
+ queued = @shutdown_mutex.synchronize do
141
+ @store_queue.push(MessageStruct.new(message, args, kwargs, block)) unless @shutdown
142
+ end
143
+ return queued if queued
144
+ end
145
+ @store.public_send(message, *args, **kwargs, &block)
118
146
  end
119
147
 
120
148
  # Returns the store we're working with
@@ -31,6 +31,15 @@ module OpenC3
31
31
  name.sub(/\*$/, '')
32
32
  end
33
33
 
34
+ # Reject names that could resolve outside the scope's targets_modified
35
+ # directory. The bucket normalizes '..' segments in keys just like the
36
+ # filesystem does, so this must be checked before any bucket call.
37
+ def self.validate_name(name)
38
+ unless OpenC3::LocalMode.safe_key?(name)
39
+ raise ArgumentError, "Invalid target file name: #{name.inspect}"
40
+ end
41
+ end
42
+
34
43
  def self.all(scope, path_matchers, target: nil)
35
44
  target = target.upcase if target
36
45
 
@@ -46,14 +55,26 @@ module OpenC3
46
55
  prefix = "#{scope}/targets/"
47
56
  modified_prefix = "#{scope}/targets_modified/"
48
57
  end
49
- result, _ = remote_target_files(bucket_client: bucket, prefix: prefix, include_temp: false, path_matchers: path_matchers)
50
- modified, temp = remote_target_files(bucket_client: bucket, prefix: modified_prefix, include_temp: include_temp, path_matchers: path_matchers)
58
+ if target and OpenC3::LocalMode.local_only_target?(target)
59
+ # Local only targets are never listed from the bucket
60
+ result = Set.new
61
+ modified = Set.new
62
+ temp = Set.new
63
+ else
64
+ result, _ = remote_target_files(bucket_client: bucket, prefix: prefix, include_temp: false, path_matchers: path_matchers)
65
+ modified, temp = remote_target_files(bucket_client: bucket, prefix: modified_prefix, include_temp: include_temp, path_matchers: path_matchers)
66
+ end
51
67
 
52
68
  # Add in local targets_modified if present
53
- if ENV['OPENC3_LOCAL_MODE']
69
+ if ENV['OPENC3_LOCAL_MODE'] or !OpenC3::LocalMode.local_only_targets.empty?
54
70
  local_modified = OpenC3::LocalMode.local_target_files(scope: scope, target: target, path_matchers: path_matchers, include_temp: include_temp)
55
71
  local_modified.each do |filename|
56
- if include_temp and filename.include?(TEMP_FOLDER)
72
+ if OpenC3::LocalMode.local_only_name?(filename)
73
+ # The local file is the only copy so it isn't marked as modified
74
+ result << filename
75
+ elsif !ENV['OPENC3_LOCAL_MODE']
76
+ next
77
+ elsif include_temp and filename.include?(TEMP_FOLDER)
57
78
  temp << filename
58
79
  else
59
80
  modified << filename unless modified.include?(filename)
@@ -100,16 +121,14 @@ module OpenC3
100
121
 
101
122
  def self.body(scope, name)
102
123
  name = strip_modified(name) # Remove '*' that indicates modified
124
+ # Local only targets are read from the local mode volume and never the bucket
125
+ if OpenC3::LocalMode.local_only_name?(name)
126
+ return local_body(scope, name)
127
+ end
103
128
  # First try opening a potentially modified version by looking for the modified target
104
129
  if ENV['OPENC3_LOCAL_MODE']
105
- local_file = OpenC3::LocalMode.open_local_file(name, scope: scope)
106
- if local_file
107
- if File.extname(name) == ".bin"
108
- return local_file.read
109
- else
110
- return local_file.read.force_encoding('UTF-8')
111
- end
112
- end
130
+ text = local_body(scope, name)
131
+ return text if text
113
132
  end
114
133
 
115
134
  bucket = Bucket.getClient()
@@ -136,6 +155,11 @@ module OpenC3
136
155
  # file listing, not a name any caller means to write. Remove it to avoid
137
156
  # creating a file with a '*' in the name.
138
157
  name = strip_modified(name)
158
+ validate_name(name)
159
+ if OpenC3::LocalMode.local_only_name?(name)
160
+ OpenC3::LocalMode.put_target_file("#{scope}/targets_modified/#{name}", text, scope: scope)
161
+ return true
162
+ end
139
163
  if ENV['OPENC3_LOCAL_MODE']
140
164
  OpenC3::LocalMode.put_target_file("#{scope}/targets_modified/#{name}", text, scope: scope)
141
165
  end
@@ -163,6 +187,11 @@ module OpenC3
163
187
  # Match body/create so deleting a name taken straight from a listing
164
188
  # removes the file the user actually picked
165
189
  name = strip_modified(name)
190
+ validate_name(name)
191
+ if OpenC3::LocalMode.local_only_name?(name)
192
+ OpenC3::LocalMode.delete_local("#{scope}/targets_modified/#{name}")
193
+ return true
194
+ end
166
195
  if ENV['OPENC3_LOCAL_MODE']
167
196
  OpenC3::LocalMode.delete_local("#{scope}/targets_modified/#{name}")
168
197
  end
@@ -177,6 +206,20 @@ module OpenC3
177
206
 
178
207
  # protected
179
208
 
209
+ def self.local_body(scope, name)
210
+ local_file = OpenC3::LocalMode.open_local_file(name, scope: scope)
211
+ return nil unless local_file
212
+ begin
213
+ if File.extname(name) == ".bin"
214
+ local_file.read
215
+ else
216
+ local_file.read.force_encoding('UTF-8')
217
+ end
218
+ ensure
219
+ local_file.close
220
+ end
221
+ end
222
+
180
223
  def self.remote_target_files(bucket_client:, prefix:, include_temp: false, path_matchers: nil)
181
224
  result = Set.new
182
225
  temp = Set.new
@@ -191,6 +234,8 @@ module OpenC3
191
234
  temp << split_key[2..-1].join('/') if include_temp
192
235
  next
193
236
  end
237
+ # Local only targets are never read from the bucket
238
+ next if OpenC3::LocalMode.local_only_target?(split_key[2])
194
239
 
195
240
  if path_matchers
196
241
  found = false
@@ -0,0 +1,193 @@
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/utilities/logger'
15
+
16
+ module OpenC3
17
+ # Tracks how far a microservice is behind the Redis streams it consumes.
18
+ #
19
+ # Every processed message updates the lag gauge metric with the maximum lag
20
+ # seen over the last metric update interval. When the lag crosses the warning
21
+ # or critical threshold a user notification is published, and a recovery
22
+ # notification is published once the microservice has caught back up (with
23
+ # hysteresis so a lag hovering around a threshold doesn't flap).
24
+ #
25
+ # Streams are trimmed by time once their data is logged, so a consumer can
26
+ # only skip trimmed data after lagging far past the critical threshold.
27
+ #
28
+ # Recovery assumes each monitor is fed by a single reader that reads every
29
+ # stream with pending entries on each XREAD (COUNT limits how many entries
30
+ # but not which streams). A backlogged topic then reports its high lag every
31
+ # read cycle, so fresh messages on other topics can't clear the alert early.
32
+ class TopicLagMonitor
33
+ OK = 0
34
+ WARN = 1
35
+ CRITICAL = 2
36
+
37
+ # Lag must drop below threshold * CLEAR_RATIO to count as recovering
38
+ CLEAR_RATIO = 0.5
39
+ # Matches Metric::UPDATE_INTERVAL so every lag spike is published
40
+ GAUGE_WINDOW_SECONDS = 5.0
41
+
42
+ attr_reader :state
43
+
44
+ # Lag in seconds that triggers the warning and critical notifications.
45
+ # A warning <= 0 disables notifications. Also used by Data Flows so its
46
+ # indicators match the notifications.
47
+ def self.thresholds
48
+ {
49
+ 'warning' => env_float('OPENC3_LAG_WARN_SECONDS', 5.0),
50
+ 'critical' => env_float('OPENC3_LAG_CRITICAL_SECONDS', 30.0),
51
+ }
52
+ end
53
+
54
+ def self.env_float(name, default)
55
+ value = ENV.fetch(name, '').strip
56
+ return default if value.empty?
57
+ Float(value)
58
+ rescue ArgumentError
59
+ default
60
+ end
61
+
62
+ def initialize(name:, logger:, metric: nil)
63
+ @name = name
64
+ @logger = logger
65
+ @metric = metric
66
+ thresholds = self.class.thresholds
67
+ @warn_seconds = thresholds['warning']
68
+ @critical_seconds = thresholds['critical']
69
+ @clear_seconds = self.class.env_float('OPENC3_LAG_CLEAR_SECONDS', 10.0)
70
+ @renotify_seconds = self.class.env_float('OPENC3_LAG_RENOTIFY_SECONDS', 300.0)
71
+ @state = OK
72
+ @max_lag = 0.0
73
+ @max_lag_topic = nil
74
+ @below_since = nil
75
+ @below_ok_since = nil
76
+ @last_notify = nil
77
+ @last_notify_level = OK
78
+ @notified = false
79
+ @gauges = {}
80
+ @mutex = Mutex.new
81
+ end
82
+
83
+ # Record that a message was read from a topic
84
+ #
85
+ # @param topic [String] Redis stream the message was read from
86
+ # @param msg_id [String] Redis stream id of the message
87
+ # @param metric_name [String] Gauge metric to update with the lag
88
+ # @param help [String] Help text for the gauge metric
89
+ # @return [Float] The lag in seconds
90
+ def record(topic, msg_id, metric_name: nil, help: nil, now: Time.now.to_f)
91
+ lag = now - (id_ms(msg_id) / 1000.0)
92
+ update_gauge(metric_name, help, lag, now) if @metric and metric_name
93
+ return lag if @warn_seconds <= 0 # Notifications disabled
94
+
95
+ # Fast path for the common case of keeping up: skip the mutex and state machine
96
+ return lag if @state == OK and lag < @warn_seconds
97
+
98
+ @mutex.synchronize { update_state(topic, lag, now) }
99
+ return lag
100
+ end
101
+
102
+ protected
103
+
104
+ # Topics read by one microservice can lag by very different amounts, so
105
+ # publish the maximum lag rather than whichever topic was read last. The
106
+ # previous window's maximum is held through the current window so a spike
107
+ # just before a window boundary is still published.
108
+ def update_gauge(metric_name, help, lag, now)
109
+ window = (now / GAUGE_WINDOW_SECONDS).to_i
110
+ gauge = @gauges[metric_name]
111
+ if gauge.nil?
112
+ gauge = @gauges[metric_name] = { window: window, max: lag, previous_max: 0.0 }
113
+ elsif window != gauge[:window]
114
+ gauge[:previous_max] = (window == gauge[:window] + 1) ? gauge[:max] : 0.0
115
+ gauge[:window] = window
116
+ gauge[:max] = lag
117
+ elsif lag > gauge[:max]
118
+ gauge[:max] = lag
119
+ else
120
+ return # Published value is unchanged
121
+ end
122
+ value = (gauge[:max] > gauge[:previous_max]) ? gauge[:max] : gauge[:previous_max]
123
+ @metric.set(name: metric_name, value: value, type: 'gauge', unit: 'seconds', help: help)
124
+ end
125
+
126
+ def update_state(topic, lag, now)
127
+ if lag < @warn_seconds * CLEAR_RATIO
128
+ @below_ok_since ||= now
129
+ else
130
+ @below_ok_since = nil
131
+ end
132
+ if lag > @max_lag
133
+ @max_lag = lag
134
+ @max_lag_topic = topic
135
+ end
136
+ level = if lag >= @critical_seconds
137
+ CRITICAL
138
+ elsif lag >= @warn_seconds
139
+ WARN
140
+ else
141
+ OK
142
+ end
143
+
144
+ if level > @state
145
+ @state = level
146
+ @below_since = nil
147
+ # Bursty load can make a microservice recover and fall behind over and
148
+ # over, so falling back to an already announced level soon after the
149
+ # last notification continues that episode rather than notifying again
150
+ unless @last_notify and level <= @last_notify_level and (now - @last_notify) < @renotify_seconds
151
+ notify_lag(topic, lag, now)
152
+ end
153
+ elsif @state != OK
154
+ threshold = (@state == CRITICAL) ? @critical_seconds : @warn_seconds
155
+ if lag < threshold * CLEAR_RATIO
156
+ @below_since ||= now
157
+ if (now - @below_since) >= @clear_seconds
158
+ @below_since = nil
159
+ if @state == CRITICAL and (@below_ok_since.nil? or (now - @below_ok_since) < @clear_seconds)
160
+ @state = WARN
161
+ @below_since = @below_ok_since
162
+ else
163
+ @state = OK
164
+ @logger.info("#{@name} has caught up processing (max lag #{@max_lag.round(1)}s on #{@max_lag_topic})", type: Logger::NOTIFICATION) if @notified
165
+ @notified = false
166
+ @max_lag = 0.0
167
+ @max_lag_topic = nil
168
+ end
169
+ end
170
+ else
171
+ @below_since = nil
172
+ notify_lag(topic, lag, now) if (now - @last_notify) >= @renotify_seconds
173
+ end
174
+ end
175
+ end
176
+
177
+ def notify_lag(topic, lag, now)
178
+ message = "#{@name} is falling behind processing: #{lag.round(1)}s behind on #{topic} (max #{@max_lag.round(1)}s on #{@max_lag_topic})"
179
+ if @state == CRITICAL
180
+ @logger.error(message, type: Logger::NOTIFICATION)
181
+ else
182
+ @logger.warn(message, type: Logger::NOTIFICATION)
183
+ end
184
+ @last_notify = now
185
+ @last_notify_level = @state
186
+ @notified = true
187
+ end
188
+
189
+ def id_ms(id)
190
+ id.to_s.split('-')[0].to_i
191
+ end
192
+ end
193
+ end
@@ -1,14 +1,14 @@
1
1
  # encoding: ascii-8bit
2
2
 
3
- OPENC3_VERSION = '7.4.1'
3
+ OPENC3_VERSION = '7.5.0'
4
4
  module OpenC3
5
5
  module Version
6
6
  MAJOR = '7'
7
- MINOR = '4'
8
- PATCH = '1'
7
+ MINOR = '5'
8
+ PATCH = '0'
9
9
  OTHER = ''
10
- BUILD = '2d4089e0687654512c2eb84722a5a4fa699dd5c3'
10
+ BUILD = 'f77108e1b9d90505ae1ba0a893150c837f879288'
11
11
  end
12
- VERSION = '7.4.1'
13
- GEM_VERSION = '7.4.1'
12
+ VERSION = '7.5.0'
13
+ GEM_VERSION = '7.5.0'
14
14
  end
@@ -1,6 +1,6 @@
1
1
  from openc3.conversions.conversion import Conversion
2
- # Using tlm() requires the following:
3
- # from openc3.api.tlm_api import tlm
2
+ # Using System.telemetry.packet() requires the following:
3
+ # from openc3.system.system import System
4
4
 
5
5
  # Custom conversion class
6
6
  # See https://docs.openc3.com/docs/configuration/conversions
@@ -20,4 +20,8 @@ class <%= conversion_class %>(Conversion):
20
20
  # Used when conversion is applied to a regular (not DERIVED) item
21
21
  # NOTE: You can also use packet.read("ITEM") to get additional values
22
22
  # return value / 2 * packet.read("OTHER_ITEM")
23
+ #
24
+ # In a READ_CONVERSION, read the latest value from a different packet in the same target
25
+ # other = System.telemetry.packet(packet.target_name, "OTHER_PACKET")
26
+ # return value * other.read("OTHER_ITEM")
23
27
  return value
@@ -20,6 +20,11 @@ module OpenC3
20
20
  # Used when conversion is applied to a regular (not DERIVED) item
21
21
  # NOTE: You can also use packet.read("ITEM") to get additional values
22
22
  # return value / 2 * packet.read("OTHER_ITEM")
23
+ #
24
+ # In a READ_CONVERSION, read the latest value from a different packet in the same target
25
+ # other = System.telemetry.packet(packet.target_name, "OTHER_PACKET")
26
+ # return value * other.read("OTHER_ITEM")
27
+ return value
23
28
  end
24
29
  end
25
30
  end
@@ -0,0 +1,62 @@
1
+ # Builds this plugin and installs it into a real COSMOS with Playwright.
2
+ #
3
+ # Runs the most recent COSMOS Core release in Docker, builds the plugin gem, and
4
+ # drives the Admin tool the way an operator would: Install From File, submit the
5
+ # variables dialog, and wait for the install to report Complete. That catches
6
+ # what unit tests can't - a plugin.txt that doesn't parse, a target that doesn't
7
+ # build, a microservice that won't start.
8
+ #
9
+ # Nothing here needs configuring as the plugin grows. Tool pages are discovered
10
+ # from the COSMOS API after install and checked for browser console errors, and
11
+ # a frontend is built with pnpm whenever the plugin has a package.json, so
12
+ # `cli generate tool` and `cli generate widget` are covered with no edit here.
13
+ #
14
+ # Runs on every commit and once a week. The weekly run is what catches a new
15
+ # COSMOS release breaking this plugin. Note that GitHub disables scheduled
16
+ # workflows in a repo with no activity for 60 days.
17
+
18
+ name: Playwright
19
+
20
+ on:
21
+ push:
22
+ branches: [main]
23
+ pull_request:
24
+ branches: ["**"]
25
+ # Off the hour because scheduled runs at :00 get queued behind everyone else's
26
+ schedule:
27
+ - cron: '27 7 * * 1'
28
+ workflow_dispatch:
29
+ inputs:
30
+ cosmos_version:
31
+ description: 'COSMOS version to run, or latest'
32
+ required: false
33
+ type: string
34
+ default: 'latest'
35
+
36
+ permissions:
37
+ contents: read
38
+
39
+ concurrency:
40
+ group: ${{ github.workflow }}-${{ github.ref }}
41
+ # Superseding a pull request run is fine, but a scheduled run and a push to
42
+ # main share a ref, so cancelling on those would have them kill each other
43
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
44
+
45
+ jobs:
46
+ playwright:
47
+ uses: OpenC3/.github/.github/workflows/plugin-playwright-reusable.yml@main
48
+ with:
49
+ plugin_directory: .
50
+ cosmos_version: ${{ inputs.cosmos_version || 'latest' }}
51
+ # Once you have added targets with `cli generate target`, list them here
52
+ # and the install is checked for them:
53
+ # expected_targets: MY_TARGET ANOTHER_TARGET
54
+ #
55
+ # install_demo: true # Uncomment if this plugin references INST/INST2
56
+ # is_tool: true # Uncomment to require that this plugin registers a tool
57
+ #
58
+ # Tool pages are checked for browser console errors automatically. Add
59
+ # regexes here for errors to allow:
60
+ # console_ignore: |
61
+ # some third party gripe
62
+ # another known message
@@ -0,0 +1,45 @@
1
+ # Builds the gem, tags the commit, and creates a GitHub release. Run it by hand
2
+ # from the Actions tab and give it a version number.
3
+ #
4
+ # Both publish steps are off by default so a first release works with no setup
5
+ # at all. Tick the box in the run dialog when you are ready to publish, after
6
+ # adding the matching secret to the repo:
7
+ #
8
+ # Deploy to OpenC3 App Store -> OPENC3_API_TOKEN (your store account token)
9
+ # Publish the gem to RubyGems -> RUBYGEMS_API_KEY (your RubyGems API key)
10
+ #
11
+ # A tool or widget frontend needs nothing set here: once the plugin has a
12
+ # package.json it is built with pnpm before the gem is packaged.
13
+
14
+ name: Release
15
+
16
+ on:
17
+ workflow_dispatch:
18
+ inputs:
19
+ version:
20
+ description: 'Version number'
21
+ required: true
22
+ type: string
23
+ deploy_to_store:
24
+ description: 'Deploy to OpenC3 App Store'
25
+ required: false
26
+ type: boolean
27
+ default: false
28
+ deploy_to_rubygems:
29
+ description: 'Publish the gem to RubyGems'
30
+ required: false
31
+ type: boolean
32
+ default: false
33
+
34
+ jobs:
35
+ release:
36
+ uses: OpenC3/.github/.github/workflows/release-cosmos-plugin-reusable.yml@main
37
+ with:
38
+ version: ${{ inputs.version }}
39
+ deploy_to_store: ${{ inputs.deploy_to_store }}
40
+ deploy_to_rubygems: ${{ inputs.deploy_to_rubygems }}
41
+ plugin_directory: .
42
+ # is_tool: true # Uncomment to fail the release if package.json is missing
43
+ secrets:
44
+ OPENC3_API_TOKEN: ${{ secrets.OPENC3_API_TOKEN }}
45
+ RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
@@ -0,0 +1,35 @@
1
+ # Runs this plugin's unit tests on every push and pull request.
2
+ #
3
+ # Python tests are discovered in tests/ or test/, Ruby specs in spec/ or specs/.
4
+ # Whichever this plugin doesn't have is skipped, so there is nothing to set up:
5
+ # add a test file and it starts running. A plugin with no tests at all passes
6
+ # with a warning until you set require_tests below.
7
+ #
8
+ # Dependencies are picked up automatically - requirements.txt for the code under
9
+ # test, <test dir>/requirements.txt for test only ones, and a Gemfile for Ruby
10
+ # when there is one.
11
+
12
+ name: Unit Tests
13
+
14
+ on:
15
+ push:
16
+ branches: [main]
17
+ pull_request:
18
+ branches: ["**"]
19
+
20
+ permissions:
21
+ contents: read
22
+
23
+ concurrency:
24
+ group: ${{ github.workflow }}-${{ github.ref }}
25
+ cancel-in-progress: true
26
+
27
+ jobs:
28
+ unit-tests:
29
+ uses: OpenC3/.github/.github/workflows/plugin-unit-tests-reusable.yml@main
30
+ with:
31
+ plugin_directory: .
32
+ # python_version: '3.12' # Uncomment to pin a different Python
33
+ # ruby_version: '3.4' # Uncomment to pin a different Ruby
34
+ # require_tests: true # Uncomment to fail the build when there are no tests
35
+ # gemfile: Gemfile.dev # Uncomment if the specs need a different Gemfile than the plugin's own
@@ -1 +1,7 @@
1
1
  node_modules
2
+ <% if @@language == 'py' -%>
3
+ .venv/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ <% end -%>