openc3 6.7.0 → 6.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/bin/openc3cli +11 -6
- data/lib/openc3/api/cmd_api.rb +15 -4
- data/lib/openc3/api/tlm_api.rb +6 -3
- data/lib/openc3/microservices/interface_microservice.rb +14 -1
- data/lib/openc3/microservices/queue_microservice.rb +166 -0
- data/lib/openc3/models/queue_model.rb +232 -0
- data/lib/openc3/script/commands.rb +3 -3
- data/lib/openc3/script/queue.rb +80 -0
- data/lib/openc3/script/script.rb +1 -0
- data/lib/openc3/script/web_socket_api.rb +11 -0
- data/lib/openc3/topics/queue_topic.rb +29 -0
- data/lib/openc3/utilities/local_mode.rb +2 -0
- data/lib/openc3/version.rb +5 -5
- 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 -3
- data/templates/widget/package.json +2 -2
- metadata +26 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0b3a2017659bdea9209911793ce6d8f53c28320df77e9c938e1a3ec6d0e88527
|
4
|
+
data.tar.gz: 950e4c86f740f303186262aa4c808f063219b7097e4b1a0c343aa83da9a71b48
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a6bcbc2924b4b7b0e7d961a0892f5e74fbb527998527f3c1ae1d7ba4c3213cbda5088864383c25bbebdeca01b09571efb596248d892fb513dc30181bbf376b1c
|
7
|
+
data.tar.gz: 0f42338482eb4069675bee41a29b0396ced047520a7300f7a627dcca2ff0fc483536448860f82e6e99a44a42b09ce64b261604fc35dd586b53ac764352ac9118
|
data/bin/openc3cli
CHANGED
@@ -24,17 +24,18 @@
|
|
24
24
|
# This file will handle OpenC3 tasks such as instantiating a new project
|
25
25
|
|
26
26
|
require 'openc3'
|
27
|
-
require 'openc3/
|
28
|
-
require 'openc3/utilities/bucket'
|
29
|
-
require 'openc3/utilities/cli_generator'
|
30
|
-
require 'openc3/models/scope_model'
|
31
|
-
require 'openc3/models/plugin_model'
|
27
|
+
require 'openc3/bridge/bridge'
|
32
28
|
require 'openc3/models/gem_model'
|
33
29
|
require 'openc3/models/migration_model'
|
30
|
+
require 'openc3/models/plugin_model'
|
34
31
|
require 'openc3/models/python_package_model'
|
32
|
+
require 'openc3/models/queue_model'
|
33
|
+
require 'openc3/models/scope_model'
|
35
34
|
require 'openc3/models/tool_model'
|
36
35
|
require 'openc3/packets/packet_config'
|
37
|
-
require 'openc3/
|
36
|
+
require 'openc3/utilities/bucket'
|
37
|
+
require 'openc3/utilities/cli_generator'
|
38
|
+
require 'openc3/utilities/local_mode'
|
38
39
|
require 'ostruct'
|
39
40
|
require 'optparse'
|
40
41
|
require 'openc3/utilities/zip'
|
@@ -1015,6 +1016,10 @@ if not ARGV[0].nil? # argument(s) given
|
|
1015
1016
|
end
|
1016
1017
|
end
|
1017
1018
|
|
1019
|
+
when 'createqueue'
|
1020
|
+
queue = OpenC3::QueueModel.new(name: ARGV[1], state: 'RELEASE', scope: ARGV[2])
|
1021
|
+
queue.create
|
1022
|
+
|
1018
1023
|
when 'destroyscope'
|
1019
1024
|
scope = OpenC3::ScopeModel.get_model(name: ARGV[1])
|
1020
1025
|
scope.destroy
|
data/lib/openc3/api/cmd_api.rb
CHANGED
@@ -14,7 +14,7 @@
|
|
14
14
|
# GNU Affero General Public License for more details.
|
15
15
|
|
16
16
|
# Modified by OpenC3, Inc.
|
17
|
-
# All changes Copyright
|
17
|
+
# All changes Copyright 2025, OpenC3, Inc.
|
18
18
|
# All Rights Reserved
|
19
19
|
#
|
20
20
|
# This file may also be used under the terms of a commercial license
|
@@ -24,6 +24,7 @@
|
|
24
24
|
# See https://github.com/OpenC3/cosmos/pull/1963
|
25
25
|
|
26
26
|
require 'openc3/api/interface_api'
|
27
|
+
require 'openc3/models/queue_model'
|
27
28
|
require 'openc3/models/target_model'
|
28
29
|
require 'openc3/topics/command_topic'
|
29
30
|
require 'openc3/topics/command_decom_topic'
|
@@ -452,7 +453,7 @@ module OpenC3
|
|
452
453
|
end
|
453
454
|
|
454
455
|
# NOTE: When adding new keywords to this method, make sure to update script/commands.rb
|
455
|
-
def _cmd_implementation(method_name, *args, range_check:, hazardous_check:, raw:, timeout: nil, log_message: nil, manual: false, validate: true,
|
456
|
+
def _cmd_implementation(method_name, *args, range_check:, hazardous_check:, raw:, timeout: nil, log_message: nil, manual: false, validate: true, queue: nil,
|
456
457
|
scope: $openc3_scope, token: $openc3_token, **kwargs)
|
457
458
|
extract_string_kwargs_to_args(args, kwargs)
|
458
459
|
unless [nil, true, false].include?(log_message)
|
@@ -538,9 +539,19 @@ module OpenC3
|
|
538
539
|
'log_message' => log_message.to_s,
|
539
540
|
'obfuscated_items' => packet['obfuscated_items'].to_s
|
540
541
|
}
|
541
|
-
|
542
|
+
# Users have to explicitly opt into a default queue by setting the OPENC3_DEFAULT_QUEUE
|
543
|
+
# At which point ALL commands will go to that queue unless they specifically opt out with queue: false
|
544
|
+
if ENV['OPENC3_DEFAULT_QUEUE'] && queue.nil?
|
545
|
+
queue = ENV['OPENC3_DEFAULT_QUEUE']
|
546
|
+
end
|
547
|
+
if queue
|
548
|
+
# Pull the command out of the script string, e.g. cmd("INST ABORT")
|
549
|
+
queued = cmd_string.split('("')[1].split('")')[0]
|
550
|
+
QueueModel.queue_command(queue, command: queued, username: username, scope: scope)
|
551
|
+
else
|
552
|
+
CommandTopic.send_command(command, timeout: timeout, scope: scope)
|
553
|
+
end
|
542
554
|
return command
|
543
555
|
end
|
544
|
-
|
545
556
|
end
|
546
557
|
end
|
data/lib/openc3/api/tlm_api.rb
CHANGED
@@ -293,7 +293,8 @@ module OpenC3
|
|
293
293
|
results << [target_name, orig_packet_name, item_name, 'WITH_UNITS'].join('__')
|
294
294
|
elsif item['format_string']
|
295
295
|
results << [target_name, orig_packet_name, item_name, 'FORMATTED'].join('__')
|
296
|
-
|
296
|
+
# This logic must match the logic in Packet#decom
|
297
|
+
elsif item['states'] or (item['read_conversion'] and item['data_type'] != 'DERIVED')
|
297
298
|
results << [target_name, orig_packet_name, item_name, 'CONVERTED'].join('__')
|
298
299
|
else
|
299
300
|
results << [target_name, orig_packet_name, item_name, 'RAW'].join('__')
|
@@ -301,13 +302,15 @@ module OpenC3
|
|
301
302
|
when 'FORMATTED'
|
302
303
|
if item['format_string']
|
303
304
|
results << [target_name, orig_packet_name, item_name, 'FORMATTED'].join('__')
|
304
|
-
|
305
|
+
# This logic must match the logic in Packet#decom
|
306
|
+
elsif item['states'] or (item['read_conversion'] and item['data_type'] != 'DERIVED')
|
305
307
|
results << [target_name, orig_packet_name, item_name, 'CONVERTED'].join('__')
|
306
308
|
else
|
307
309
|
results << [target_name, orig_packet_name, item_name, 'RAW'].join('__')
|
308
310
|
end
|
309
311
|
when 'CONVERTED'
|
310
|
-
|
312
|
+
# This logic must match the logic in Packet#decom
|
313
|
+
if item['states'] or (item['read_conversion'] and item['data_type'] != 'DERIVED')
|
311
314
|
results << [target_name, orig_packet_name, item_name, 'CONVERTED'].join('__')
|
312
315
|
else
|
313
316
|
results << [target_name, orig_packet_name, item_name, 'RAW'].join('__')
|
@@ -298,7 +298,7 @@ module OpenC3
|
|
298
298
|
@interface.write(command)
|
299
299
|
|
300
300
|
command.obfuscate
|
301
|
-
|
301
|
+
|
302
302
|
if command.validator and validate
|
303
303
|
begin
|
304
304
|
result, reason = command.validator.post_check(command)
|
@@ -559,6 +559,19 @@ module OpenC3
|
|
559
559
|
target.interface = new_interface
|
560
560
|
end
|
561
561
|
@interface = new_interface
|
562
|
+
|
563
|
+
# Update the model
|
564
|
+
if @interface_or_router == 'INTERFACE'
|
565
|
+
interface_model = InterfaceModel.get(name: @interface.name, scope: @scope)
|
566
|
+
# config_params[0] is the filename so set the rest
|
567
|
+
interface_model['config_params'][1..-1] = *params
|
568
|
+
InterfaceModel.set(interface_model, scope: @scope)
|
569
|
+
else
|
570
|
+
router_model = RouterModel.get(name: @interface.name, scope: @scope)
|
571
|
+
# config_params[0] is the filename so set the rest
|
572
|
+
router_model['config_params'][1..-1] = *params
|
573
|
+
RouterModel.set(router_model, scope: @scope)
|
574
|
+
end
|
562
575
|
end
|
563
576
|
|
564
577
|
@interface.state = 'ATTEMPTING'
|
@@ -0,0 +1,166 @@
|
|
1
|
+
# encoding: ascii-8bit
|
2
|
+
|
3
|
+
# Copyright 2025 OpenC3, Inc.
|
4
|
+
# All Rights Reserved.
|
5
|
+
#
|
6
|
+
# This program is free software; you can modify and/or redistribute it
|
7
|
+
# under the terms of the GNU Affero General Public License
|
8
|
+
# as published by the Free Software Foundation; version 3 with
|
9
|
+
# attribution addendums as found in the LICENSE.txt
|
10
|
+
#
|
11
|
+
# This program is distributed in the hope that it will be useful,
|
12
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
13
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
14
|
+
# GNU Affero General Public License for more details.
|
15
|
+
#
|
16
|
+
# This file may also be used under the terms of a commercial license
|
17
|
+
# if purchased from OpenC3, Inc.
|
18
|
+
|
19
|
+
require 'openc3/microservices/microservice'
|
20
|
+
require 'openc3/topics/queue_topic'
|
21
|
+
require 'openc3/utilities/authentication'
|
22
|
+
require 'openc3/script'
|
23
|
+
|
24
|
+
module OpenC3
|
25
|
+
# The queue processor runs in a single thread and processes commands via cmd_api.
|
26
|
+
class QueueProcessor
|
27
|
+
attr_accessor :state
|
28
|
+
attr_reader :name, :scope
|
29
|
+
|
30
|
+
def initialize(name:, state:, logger:, scope:)
|
31
|
+
@name = name
|
32
|
+
@logger = logger
|
33
|
+
@scope = scope
|
34
|
+
@state = state
|
35
|
+
@cancel_thread = false
|
36
|
+
end
|
37
|
+
|
38
|
+
def get_token(username)
|
39
|
+
if ENV['OPENC3_API_CLIENT'].nil?
|
40
|
+
ENV['OPENC3_API_PASSWORD'] ||= ENV['OPENC3_SERVICE_PASSWORD']
|
41
|
+
return OpenC3Authentication.new().token
|
42
|
+
else
|
43
|
+
# Check for offline access token
|
44
|
+
model = nil
|
45
|
+
model = OpenC3::OfflineAccessModel.get_model(name: username, scope: @scope) if username and username != ''
|
46
|
+
if model and model.offline_access_token
|
47
|
+
auth = OpenC3KeycloakAuthentication.new(ENV['OPENC3_KEYCLOAK_URL'])
|
48
|
+
return auth.get_token_from_refresh_token(model.offline_access_token)
|
49
|
+
else
|
50
|
+
return nil
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def run
|
56
|
+
while true
|
57
|
+
if @state == 'RELEASE'
|
58
|
+
process_queued_commands()
|
59
|
+
else
|
60
|
+
sleep 0.2
|
61
|
+
end
|
62
|
+
break if @cancel_thread
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def process_queued_commands
|
67
|
+
while @state == 'RELEASE'
|
68
|
+
begin
|
69
|
+
_queue_name, command_data, _timestamp = Store.bzpopmin("#{@scope}:#{@name}", timeout: 0.2)
|
70
|
+
if command_data
|
71
|
+
command = JSON.parse(command_data)
|
72
|
+
username = command['username']
|
73
|
+
token = get_token(username)
|
74
|
+
# It's important to set queue: false here to avoid infinite recursion when
|
75
|
+
# OPENC3_DEFAULT_QUEUE is set because commands would be re-queued to the default queue
|
76
|
+
cmd_no_hazardous_check(command['value'], queue: false, scope: @scope, token: token)
|
77
|
+
end
|
78
|
+
rescue StandardError => e
|
79
|
+
@logger.error "QueueProcessor failed to process command from queue #{@name}\n#{e.message}"
|
80
|
+
end
|
81
|
+
break if @cancel_thread
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def shutdown
|
86
|
+
@cancel_thread = true
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# The queue microservice starts a processor then gets the queue entries from redis.
|
91
|
+
# It then monitors the QueueTopic for changes.
|
92
|
+
class QueueMicroservice < Microservice
|
93
|
+
attr_reader :name, :processor, :processor_thread
|
94
|
+
|
95
|
+
def initialize(*args)
|
96
|
+
super(*args)
|
97
|
+
@queue_name = @name.split('__')[2]
|
98
|
+
|
99
|
+
initial_state = 'HOLD'
|
100
|
+
(@config['options'] || []).each do |option|
|
101
|
+
case option[0].upcase
|
102
|
+
when 'QUEUE_STATE'
|
103
|
+
initial_state = option[1]
|
104
|
+
else
|
105
|
+
@logger.error("Unknown option passed to microservice #{@name}: #{option}")
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
@processor = QueueProcessor.new(name: @queue_name, state: initial_state, logger: @logger, scope: @scope)
|
110
|
+
@processor_thread = nil
|
111
|
+
@read_topic = true
|
112
|
+
end
|
113
|
+
|
114
|
+
def run
|
115
|
+
@logger.info "QueueMicroservice running"
|
116
|
+
@processor_thread = Thread.new { @processor.run }
|
117
|
+
|
118
|
+
# Let the frontend know that the microservice has been deployed and is running
|
119
|
+
notification = {
|
120
|
+
'kind' => 'deployed',
|
121
|
+
# name and updated_at fields are required for Event formatting
|
122
|
+
'data' => JSON.generate({
|
123
|
+
'name' => @name,
|
124
|
+
'updated_at' => Time.now.to_nsec_from_epoch,
|
125
|
+
}),
|
126
|
+
}
|
127
|
+
QueueTopic.write_notification(notification, scope: @scope)
|
128
|
+
|
129
|
+
loop do
|
130
|
+
break if @cancel_thread
|
131
|
+
block_for_updates()
|
132
|
+
end
|
133
|
+
@processor.shutdown()
|
134
|
+
@processor_thread.join() if @processor_thread
|
135
|
+
@logger.info "QueueMicroservice exiting"
|
136
|
+
end
|
137
|
+
|
138
|
+
def block_for_updates
|
139
|
+
@read_topic = true
|
140
|
+
while @read_topic && !@cancel_thread
|
141
|
+
begin
|
142
|
+
QueueTopic.read_topics(@topics) do |_topic, _msg_id, msg_hash, _redis|
|
143
|
+
data = JSON.parse(msg_hash['data']) if msg_hash['data']
|
144
|
+
if data['name'] == @queue_name and msg_hash['kind'] == 'updated'
|
145
|
+
@processor.state = data['state']
|
146
|
+
end
|
147
|
+
end
|
148
|
+
rescue StandardError => e
|
149
|
+
@logger.error "QueueMicroservice failed to read topics #{@topics}\n#{e.formatted}"
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
def shutdown
|
155
|
+
@read_topic = false
|
156
|
+
@processor.shutdown() if @processor
|
157
|
+
super
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
if __FILE__ == $0
|
163
|
+
OpenC3::QueueMicroservice.run
|
164
|
+
OpenC3::ThreadManager.instance.shutdown
|
165
|
+
OpenC3::ThreadManager.instance.join
|
166
|
+
end
|
@@ -0,0 +1,232 @@
|
|
1
|
+
# encoding: ascii-8bit
|
2
|
+
|
3
|
+
# Copyright 2025 OpenC3, Inc.
|
4
|
+
# All Rights Reserved.
|
5
|
+
#
|
6
|
+
# This program is free software; you can modify and/or redistribute it
|
7
|
+
# under the terms of the GNU Affero General Public License
|
8
|
+
# as published by the Free Software Foundation; version 3 with
|
9
|
+
# attribution addendums as found in the LICENSE.txt
|
10
|
+
#
|
11
|
+
# This program is distributed in the hope that it will be useful,
|
12
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
13
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
14
|
+
# GNU Affero General Public License for more details.
|
15
|
+
|
16
|
+
# This file may also be used under the terms of a commercial license
|
17
|
+
# if purchased from OpenC3, Inc.
|
18
|
+
|
19
|
+
require 'openc3/models/model'
|
20
|
+
require 'openc3/models/microservice_model'
|
21
|
+
require 'openc3/topics/queue_topic'
|
22
|
+
require 'openc3/utilities/logger'
|
23
|
+
|
24
|
+
module OpenC3
|
25
|
+
class QueueError < StandardError; end
|
26
|
+
|
27
|
+
class QueueModel < Model
|
28
|
+
PRIMARY_KEY = 'openc3__queue'.freeze
|
29
|
+
|
30
|
+
@@class_mutex = Mutex.new
|
31
|
+
|
32
|
+
# NOTE: The following three class methods are used by the ModelController
|
33
|
+
# and are reimplemented to enable various Model class methods to work
|
34
|
+
def self.get(name:, scope:)
|
35
|
+
super("#{scope}__#{PRIMARY_KEY}", name: name)
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.names(scope:)
|
39
|
+
super("#{scope}__#{PRIMARY_KEY}")
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.all(scope:)
|
43
|
+
super("#{scope}__#{PRIMARY_KEY}")
|
44
|
+
end
|
45
|
+
# END NOTE
|
46
|
+
|
47
|
+
def self.queue_command(name, command:, username:, scope:)
|
48
|
+
model = get_model(name: name, scope: scope)
|
49
|
+
raise QueueError, "Queue '#{name}' not found in scope '#{scope}'" unless model
|
50
|
+
|
51
|
+
if model.state != 'DISABLE'
|
52
|
+
result = Store.zrevrange("#{scope}:#{name}", 0, 0, with_scores: true)
|
53
|
+
if result.empty?
|
54
|
+
index = 1.0
|
55
|
+
else
|
56
|
+
index = result[0][1].to_f + 1
|
57
|
+
end
|
58
|
+
Store.zadd("#{scope}:#{name}", index, { username: username, value: command, timestamp: Time.now.to_nsec_from_epoch }.to_json)
|
59
|
+
model.notify(kind: 'command')
|
60
|
+
else
|
61
|
+
raise QueueError, "Queue '#{name}' is disabled. Command '#{command}' not queued."
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
attr_accessor :name, :state
|
66
|
+
|
67
|
+
def initialize(name:, scope:, state: 'HOLD', updated_at: nil)
|
68
|
+
super("#{scope}__#{PRIMARY_KEY}", name: name, updated_at: updated_at, scope: scope)
|
69
|
+
@microservice_name = "#{scope}__QUEUE__#{name}"
|
70
|
+
if %w(HOLD RELEASE DISABLE).include?(state)
|
71
|
+
@state = state
|
72
|
+
else
|
73
|
+
@state = 'HOLD'
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def create(update: false, force: false, queued: false)
|
78
|
+
super(update: update, force: force, queued: queued)
|
79
|
+
if update
|
80
|
+
notify(kind: 'updated')
|
81
|
+
else
|
82
|
+
deploy()
|
83
|
+
notify(kind: 'created')
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# @return [Hash] generated from the QueueModel
|
88
|
+
def as_json(*a)
|
89
|
+
return {
|
90
|
+
'name' => @name,
|
91
|
+
'scope' => @scope,
|
92
|
+
'state' => @state,
|
93
|
+
'updated_at' => @updated_at
|
94
|
+
}
|
95
|
+
end
|
96
|
+
|
97
|
+
# @return [] update the redis stream / queue topic that something has changed
|
98
|
+
def notify(kind:)
|
99
|
+
notification = {
|
100
|
+
'kind' => kind,
|
101
|
+
'data' => JSON.generate(as_json(:allow_nan => true)),
|
102
|
+
}
|
103
|
+
QueueTopic.write_notification(notification, scope: @scope)
|
104
|
+
end
|
105
|
+
|
106
|
+
def insert_command(index, command_data)
|
107
|
+
if @state == 'DISABLE'
|
108
|
+
raise QueueError, "Queue '#{@name}' is disabled. Command '#{command_data['value']}' not queued."
|
109
|
+
end
|
110
|
+
|
111
|
+
unless index
|
112
|
+
result = Store.zrevrange("#{@scope}:#{@name}", 0, 0, with_scores: true)
|
113
|
+
if result.empty?
|
114
|
+
index = 1.0
|
115
|
+
else
|
116
|
+
index = result[0][1].to_f + 1
|
117
|
+
end
|
118
|
+
end
|
119
|
+
Store.zadd("#{@scope}:#{@name}", index, command_data.to_json)
|
120
|
+
notify(kind: 'command')
|
121
|
+
end
|
122
|
+
|
123
|
+
def update_command(index:, command:, username:)
|
124
|
+
if @state == 'DISABLE'
|
125
|
+
raise QueueError, "Queue '#{@name}' is disabled. Command at index #{index} not updated."
|
126
|
+
end
|
127
|
+
|
128
|
+
# Check if command exists at the given index
|
129
|
+
existing = Store.zrangebyscore("#{@scope}:#{@name}", index, index)
|
130
|
+
if existing.empty?
|
131
|
+
raise QueueError, "No command found at index #{index} in queue '#{@name}'"
|
132
|
+
end
|
133
|
+
|
134
|
+
# Remove the existing command and add the new one at the same index
|
135
|
+
Store.zremrangebyscore("#{@scope}:#{@name}", index, index)
|
136
|
+
command_data = { username: username, value: command, timestamp: Time.now.to_nsec_from_epoch }
|
137
|
+
Store.zadd("#{@scope}:#{@name}", index, command_data.to_json)
|
138
|
+
notify(kind: 'command')
|
139
|
+
end
|
140
|
+
|
141
|
+
def remove_command(index = nil)
|
142
|
+
if @state == 'DISABLE'
|
143
|
+
raise QueueError, "Queue '#{@name}' is disabled. Command not removed."
|
144
|
+
end
|
145
|
+
|
146
|
+
if index
|
147
|
+
# Remove specific index
|
148
|
+
result = Store.zrangebyscore("#{@scope}:#{@name}", index, index)
|
149
|
+
if result.empty?
|
150
|
+
return nil
|
151
|
+
else
|
152
|
+
Store.zremrangebyscore("#{@scope}:#{@name}", index, index)
|
153
|
+
command_data = JSON.parse(result[0])
|
154
|
+
command_data['index'] = index.to_f
|
155
|
+
notify(kind: 'command')
|
156
|
+
return command_data
|
157
|
+
end
|
158
|
+
else
|
159
|
+
# Remove first element (lowest score)
|
160
|
+
result = Store.zrange("#{@scope}:#{@name}", 0, 0, with_scores: true)
|
161
|
+
if result.empty?
|
162
|
+
return nil
|
163
|
+
else
|
164
|
+
score = result[0][1]
|
165
|
+
Store.zremrangebyscore("#{@scope}:#{@name}", score, score)
|
166
|
+
command_data = JSON.parse(result[0][0])
|
167
|
+
command_data['index'] = score.to_f
|
168
|
+
notify(kind: 'command')
|
169
|
+
return command_data
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
def list
|
175
|
+
return Store.zrange("#{@scope}:#{@name}", 0, -1, with_scores: true).map do |item|
|
176
|
+
result = JSON.parse(item[0])
|
177
|
+
result['index'] = item[1].to_f
|
178
|
+
result
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
def create_microservice(topics:)
|
183
|
+
# queue Microservice
|
184
|
+
microservice = MicroserviceModel.new(
|
185
|
+
name: @microservice_name,
|
186
|
+
folder_name: nil,
|
187
|
+
cmd: ['ruby', 'queue_microservice.rb', @microservice_name],
|
188
|
+
work_dir: '/openc3/lib/openc3/microservices',
|
189
|
+
options: [
|
190
|
+
["QUEUE_STATE", @state],
|
191
|
+
],
|
192
|
+
topics: topics,
|
193
|
+
target_names: [],
|
194
|
+
plugin: nil,
|
195
|
+
scope: @scope
|
196
|
+
)
|
197
|
+
microservice.create
|
198
|
+
end
|
199
|
+
|
200
|
+
def deploy
|
201
|
+
topics = ["#{@scope}__#{QueueTopic::PRIMARY_KEY}"]
|
202
|
+
if MicroserviceModel.get_model(name: @microservice_name, scope: @scope).nil?
|
203
|
+
create_microservice(topics: topics)
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
def undeploy
|
208
|
+
model = MicroserviceModel.get_model(name: @microservice_name, scope: @scope)
|
209
|
+
if model
|
210
|
+
# Let the frontend know that the microservice is shutting down
|
211
|
+
# Custom event which matches the 'deployed' event in QueueMicroservice
|
212
|
+
notification = {
|
213
|
+
'kind' => 'undeployed',
|
214
|
+
# name and updated_at fields are required for Event formatting
|
215
|
+
'data' => JSON.generate({
|
216
|
+
'name' => @microservice_name,
|
217
|
+
'updated_at' => Time.now.to_nsec_from_epoch,
|
218
|
+
}),
|
219
|
+
}
|
220
|
+
QueueTopic.write_notification(notification, scope: @scope)
|
221
|
+
model.destroy
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
# Delete the model from the Store
|
226
|
+
def destroy
|
227
|
+
undeploy()
|
228
|
+
Store.zremrangebyrank("#{@scope}:#{@name}", 0, -1)
|
229
|
+
super()
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
@@ -122,7 +122,7 @@ module OpenC3
|
|
122
122
|
# except for range_check, hazardous_check, and raw as they are part of the cmd name
|
123
123
|
# manual is always false since this is called from script and that is the default
|
124
124
|
# NOTE: This is a helper method and should not be called directly
|
125
|
-
def _cmd(cmd, cmd_no_hazardous, *args, timeout: nil, log_message: nil, validate: true, scope: $openc3_scope, token: $openc3_token, **kwargs)
|
125
|
+
def _cmd(cmd, cmd_no_hazardous, *args, timeout: nil, log_message: nil, validate: true, queue: nil, scope: $openc3_scope, token: $openc3_token, **kwargs)
|
126
126
|
extract_string_kwargs_to_args(args, kwargs)
|
127
127
|
raw = cmd.include?('raw')
|
128
128
|
no_range = cmd.include?('no_range') || cmd.include?('no_checks')
|
@@ -132,7 +132,7 @@ module OpenC3
|
|
132
132
|
else
|
133
133
|
begin
|
134
134
|
begin
|
135
|
-
command = $api_server.method_missing(cmd, *args, timeout: timeout, log_message: log_message, validate: validate, scope: scope, token: token)
|
135
|
+
command = $api_server.method_missing(cmd, *args, timeout: timeout, log_message: log_message, validate: validate, queue: queue, scope: scope, token: token)
|
136
136
|
if log_message.nil? or log_message
|
137
137
|
_log_cmd(command, raw, no_range, no_hazardous)
|
138
138
|
end
|
@@ -140,7 +140,7 @@ module OpenC3
|
|
140
140
|
# This opens a prompt at which point they can cancel and stop the script
|
141
141
|
# or say Yes and send the command. Thus we don't care about the return value.
|
142
142
|
prompt_for_hazardous(e.target_name, e.cmd_name, e.hazardous_description)
|
143
|
-
command = $api_server.method_missing(cmd_no_hazardous, *args, timeout: timeout, log_message: log_message, validate: validate, scope: scope, token: token)
|
143
|
+
command = $api_server.method_missing(cmd_no_hazardous, *args, timeout: timeout, log_message: log_message, validate: validate, queue: queue, scope: scope, token: token)
|
144
144
|
if log_message.nil? or log_message
|
145
145
|
_log_cmd(command, raw, no_range, no_hazardous)
|
146
146
|
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# encoding: ascii-8bit
|
2
|
+
|
3
|
+
# Copyright 2025 OpenC3, Inc.
|
4
|
+
# All Rights Reserved.
|
5
|
+
#
|
6
|
+
# This program is free software; you can modify and/or redistribute it
|
7
|
+
# under the terms of the GNU Affero General Public License
|
8
|
+
# as published by the Free Software Foundation; version 3 with
|
9
|
+
# attribution addendums as found in the LICENSE.txt
|
10
|
+
#
|
11
|
+
# This program is distributed in the hope that it will be useful,
|
12
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
13
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
14
|
+
# GNU Affero General Public License for more details.
|
15
|
+
|
16
|
+
# This file may also be used under the terms of a commercial license
|
17
|
+
# if purchased from OpenC3, Inc.
|
18
|
+
|
19
|
+
require 'openc3/script/extract'
|
20
|
+
|
21
|
+
module OpenC3
|
22
|
+
module Script
|
23
|
+
include Extract
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
# Helper method that makes the request and parses the response
|
28
|
+
def _make_request(action:, verb:, uri:, scope:, data: nil)
|
29
|
+
response = $api_server.request(verb, uri, data: data, json: data.nil? ? false : true, scope: scope)
|
30
|
+
if response.nil?
|
31
|
+
raise "Failed to #{action}. No response from server."
|
32
|
+
elsif response.status != 200 and response.status != 201
|
33
|
+
result = JSON.parse(response.body, :allow_nan => true, :create_additions => true)
|
34
|
+
raise "Failed to #{action} due to #{result['message']}"
|
35
|
+
end
|
36
|
+
return JSON.parse(response.body, :allow_nan => true, :create_additions => true)
|
37
|
+
end
|
38
|
+
|
39
|
+
def queue_all(scope: $openc3_scope)
|
40
|
+
return _make_request(action: 'index queue', verb: 'get', uri: "/openc3-api/queues", scope: scope)
|
41
|
+
end
|
42
|
+
|
43
|
+
def queue_get(name, scope: $openc3_scope)
|
44
|
+
return _make_request(action: 'get queue', verb: 'get', uri: "/openc3-api/queues/#{name}", scope: scope)
|
45
|
+
end
|
46
|
+
|
47
|
+
def queue_list(name, scope: $openc3_scope)
|
48
|
+
return _make_request(action: 'list queue', verb: 'get', uri: "/openc3-api/queues/#{name}/list", scope: scope)
|
49
|
+
end
|
50
|
+
|
51
|
+
def queue_create(name, state: 'HOLD', scope: $openc3_scope)
|
52
|
+
data = {}
|
53
|
+
data['state'] = state
|
54
|
+
return _make_request(action: 'create queue', verb: 'post', uri: "/openc3-api/queues/#{name}", data: data, scope: scope)
|
55
|
+
end
|
56
|
+
|
57
|
+
def queue_hold(name, scope: $openc3_scope)
|
58
|
+
return _make_request(action: 'hold queue', verb: 'post', uri: "/openc3-api/queues/#{name}/hold", scope: scope)
|
59
|
+
end
|
60
|
+
|
61
|
+
def queue_release(name, scope: $openc3_scope)
|
62
|
+
return _make_request(action: 'release queue', verb: 'post', uri: "/openc3-api/queues/#{name}/release", scope: scope)
|
63
|
+
end
|
64
|
+
|
65
|
+
def queue_disable(name, scope: $openc3_scope)
|
66
|
+
return _make_request(action: 'disable queue', verb: 'post', uri: "/openc3-api/queues/#{name}/disable", scope: scope)
|
67
|
+
end
|
68
|
+
|
69
|
+
def queue_exec(name, index: nil, scope: $openc3_scope)
|
70
|
+
data = {}
|
71
|
+
data['index'] = index if index
|
72
|
+
return _make_request(action: 'exec command', verb: 'post', uri: "/openc3-api/queues/#{name}/exec_command", data: data, scope: scope)
|
73
|
+
end
|
74
|
+
|
75
|
+
def queue_delete(name, scope: $openc3_scope)
|
76
|
+
return _make_request(action: 'delete queue', verb: 'delete', uri: "/openc3-api/queues/#{name}", scope: scope)
|
77
|
+
end
|
78
|
+
alias queue_destroy queue_delete
|
79
|
+
end
|
80
|
+
end
|
data/lib/openc3/script/script.rb
CHANGED
@@ -34,6 +34,7 @@ require 'openc3/script/limits'
|
|
34
34
|
require 'openc3/script/metadata'
|
35
35
|
require 'openc3/script/packages'
|
36
36
|
require 'openc3/script/plugins'
|
37
|
+
require 'openc3/script/queue'
|
37
38
|
require 'openc3/script/screen'
|
38
39
|
require 'openc3/script/script_runner'
|
39
40
|
require 'openc3/script/storage'
|
@@ -306,6 +306,17 @@ module OpenC3
|
|
306
306
|
end
|
307
307
|
end
|
308
308
|
|
309
|
+
# Queue WebSocket
|
310
|
+
class QueueEventsWebSocketApi < CmdTlmWebSocketApi
|
311
|
+
def initialize(history_count: 0, url: nil, write_timeout: 10.0, read_timeout: 10.0, connect_timeout: 5.0, authentication: nil, scope: $openc3_scope)
|
312
|
+
@identifier = {
|
313
|
+
channel: "QueueEventsChannel",
|
314
|
+
history_count: history_count
|
315
|
+
}
|
316
|
+
super(url: url, write_timeout: write_timeout, read_timeout: read_timeout, connect_timeout: connect_timeout, authentication: authentication, scope: scope)
|
317
|
+
end
|
318
|
+
end
|
319
|
+
|
309
320
|
# Streaming API WebSocket
|
310
321
|
class StreamingWebSocketApi < CmdTlmWebSocketApi
|
311
322
|
def initialize(url: nil, write_timeout: 10.0, read_timeout: 10.0, connect_timeout: 5.0, authentication: nil, scope: $openc3_scope)
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# encoding: ascii-8bit
|
2
|
+
|
3
|
+
# Copyright 2025 OpenC3, Inc.
|
4
|
+
# All Rights Reserved.
|
5
|
+
#
|
6
|
+
# This program is free software; you can modify and/or redistribute it
|
7
|
+
# under the terms of the GNU Affero General Public License
|
8
|
+
# as published by the Free Software Foundation; version 3 with
|
9
|
+
# attribution addendums as found in the LICENSE.txt
|
10
|
+
#
|
11
|
+
# This program is distributed in the hope that it will be useful,
|
12
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
13
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
14
|
+
# GNU Affero General Public License for more details.
|
15
|
+
|
16
|
+
# This file may also be used under the terms of a commercial license
|
17
|
+
# if purchased from OpenC3, Inc.
|
18
|
+
|
19
|
+
require 'openc3/topics/topic'
|
20
|
+
|
21
|
+
module OpenC3
|
22
|
+
class QueueTopic < Topic
|
23
|
+
PRIMARY_KEY = "openc3_queue"
|
24
|
+
|
25
|
+
def self.write_notification(notification, scope:)
|
26
|
+
Topic.write_topic("#{scope}__#{PRIMARY_KEY}", notification, '*', 1000)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -31,6 +31,7 @@ module OpenC3
|
|
31
31
|
'openc3-cosmos-tool-admin',
|
32
32
|
'openc3-cosmos-tool-bucketexplorer',
|
33
33
|
'openc3-cosmos-tool-cmdsender',
|
34
|
+
'openc3-cosmos-tool-cmdqueue',
|
34
35
|
'openc3-cosmos-tool-cmdhistory',
|
35
36
|
'openc3-cosmos-tool-cmdtlmserver',
|
36
37
|
'openc3-cosmos-tool-dataextractor',
|
@@ -50,6 +51,7 @@ module OpenC3
|
|
50
51
|
'openc3-cosmos-tool-calendar',
|
51
52
|
'openc3-cosmos-tool-grafana',
|
52
53
|
'openc3-cosmos-tool-systemhealth',
|
54
|
+
'openc3-cosmos-tool-logexplorer',
|
53
55
|
'openc3-tool-base',
|
54
56
|
]
|
55
57
|
|
data/lib/openc3/version.rb
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
# encoding: ascii-8bit
|
2
2
|
|
3
|
-
OPENC3_VERSION = '6.
|
3
|
+
OPENC3_VERSION = '6.8.0'
|
4
4
|
module OpenC3
|
5
5
|
module Version
|
6
6
|
MAJOR = '6'
|
7
|
-
MINOR = '
|
7
|
+
MINOR = '8'
|
8
8
|
PATCH = '0'
|
9
9
|
OTHER = ''
|
10
|
-
BUILD = '
|
10
|
+
BUILD = '35fc05d093679cc901fc15e31a9af4b71fad61b4'
|
11
11
|
end
|
12
|
-
VERSION = '6.
|
13
|
-
GEM_VERSION = '6.
|
12
|
+
VERSION = '6.8.0'
|
13
|
+
GEM_VERSION = '6.8.0'
|
14
14
|
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "<%= tool_name %>",
|
3
|
-
"version": "6.
|
3
|
+
"version": "6.8.0",
|
4
4
|
"scripts": {
|
5
5
|
"ng": "ng",
|
6
6
|
"start": "ng serve",
|
@@ -23,7 +23,7 @@
|
|
23
23
|
"@angular/platform-browser-dynamic": "^18.2.6",
|
24
24
|
"@angular/router": "^18.2.6",
|
25
25
|
"@astrouxds/astro-web-components": "^7.24.0",
|
26
|
-
"@openc3/js-common": "6.
|
26
|
+
"@openc3/js-common": "6.8.0",
|
27
27
|
"rxjs": "~7.8.0",
|
28
28
|
"single-spa": "^5.9.5",
|
29
29
|
"single-spa-angular": "^9.2.0",
|
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "<%= tool_name %>",
|
3
|
-
"version": "6.
|
3
|
+
"version": "6.8.0",
|
4
4
|
"private": true,
|
5
5
|
"type": "module",
|
6
6
|
"scripts": {
|
@@ -11,8 +11,8 @@
|
|
11
11
|
},
|
12
12
|
"dependencies": {
|
13
13
|
"@astrouxds/astro-web-components": "^7.24.0",
|
14
|
-
"@openc3/js-common": "6.
|
15
|
-
"@openc3/vue-common": "6.
|
14
|
+
"@openc3/js-common": "6.8.0",
|
15
|
+
"@openc3/vue-common": "6.8.0",
|
16
16
|
"axios": "^1.7.7",
|
17
17
|
"date-fns": "^4.1.0",
|
18
18
|
"lodash": "^4.17.21",
|
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "<%= widget_name %>",
|
3
|
-
"version": "6.
|
3
|
+
"version": "6.8.0",
|
4
4
|
"private": true,
|
5
5
|
"type": "module",
|
6
6
|
"scripts": {
|
@@ -8,7 +8,7 @@
|
|
8
8
|
},
|
9
9
|
"dependencies": {
|
10
10
|
"@astrouxds/astro-web-components": "^7.24.0",
|
11
|
-
"@openc3/vue-common": "6.
|
11
|
+
"@openc3/vue-common": "6.8.0",
|
12
12
|
"vuetify": "^3.7.1"
|
13
13
|
},
|
14
14
|
"devDependencies": {
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: openc3
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 6.
|
4
|
+
version: 6.8.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ryan Melton
|
@@ -184,14 +184,14 @@ dependencies:
|
|
184
184
|
requirements:
|
185
185
|
- - "~>"
|
186
186
|
- !ruby/object:Gem::Version
|
187
|
-
version: '3.
|
187
|
+
version: '3.1'
|
188
188
|
type: :runtime
|
189
189
|
prerelease: false
|
190
190
|
version_requirements: !ruby/object:Gem::Requirement
|
191
191
|
requirements:
|
192
192
|
- - "~>"
|
193
193
|
- !ruby/object:Gem::Version
|
194
|
-
version: '3.
|
194
|
+
version: '3.1'
|
195
195
|
- !ruby/object:Gem::Dependency
|
196
196
|
name: rackup
|
197
197
|
requirement: !ruby/object:Gem::Requirement
|
@@ -800,28 +800,42 @@ dependencies:
|
|
800
800
|
requirements:
|
801
801
|
- - "~>"
|
802
802
|
- !ruby/object:Gem::Version
|
803
|
-
version: '0.
|
803
|
+
version: '0.22'
|
804
804
|
type: :development
|
805
805
|
prerelease: false
|
806
806
|
version_requirements: !ruby/object:Gem::Requirement
|
807
807
|
requirements:
|
808
808
|
- - "~>"
|
809
809
|
- !ruby/object:Gem::Version
|
810
|
-
version: '0.
|
810
|
+
version: '0.22'
|
811
|
+
- !ruby/object:Gem::Dependency
|
812
|
+
name: rexml
|
813
|
+
requirement: !ruby/object:Gem::Requirement
|
814
|
+
requirements:
|
815
|
+
- - '='
|
816
|
+
- !ruby/object:Gem::Version
|
817
|
+
version: 3.4.1
|
818
|
+
type: :development
|
819
|
+
prerelease: false
|
820
|
+
version_requirements: !ruby/object:Gem::Requirement
|
821
|
+
requirements:
|
822
|
+
- - '='
|
823
|
+
- !ruby/object:Gem::Version
|
824
|
+
version: 3.4.1
|
811
825
|
- !ruby/object:Gem::Dependency
|
812
826
|
name: simplecov-cobertura
|
813
827
|
requirement: !ruby/object:Gem::Requirement
|
814
828
|
requirements:
|
815
829
|
- - "~>"
|
816
830
|
- !ruby/object:Gem::Version
|
817
|
-
version: '
|
831
|
+
version: '3.0'
|
818
832
|
type: :development
|
819
833
|
prerelease: false
|
820
834
|
version_requirements: !ruby/object:Gem::Requirement
|
821
835
|
requirements:
|
822
836
|
- - "~>"
|
823
837
|
- !ruby/object:Gem::Version
|
824
|
-
version: '
|
838
|
+
version: '3.0'
|
825
839
|
description: |2
|
826
840
|
OpenC3 provides all the functionality needed to send
|
827
841
|
commands to and receive data from one or more embedded systems
|
@@ -1052,6 +1066,7 @@ files:
|
|
1052
1066
|
- lib/openc3/microservices/multi_microservice.rb
|
1053
1067
|
- lib/openc3/microservices/periodic_microservice.rb
|
1054
1068
|
- lib/openc3/microservices/plugin_microservice.rb
|
1069
|
+
- lib/openc3/microservices/queue_microservice.rb
|
1055
1070
|
- lib/openc3/microservices/reducer_microservice.rb
|
1056
1071
|
- lib/openc3/microservices/router_microservice.rb
|
1057
1072
|
- lib/openc3/microservices/scope_cleanup_microservice.rb
|
@@ -1087,6 +1102,7 @@ files:
|
|
1087
1102
|
- lib/openc3/models/plugin_store_model.rb
|
1088
1103
|
- lib/openc3/models/process_status_model.rb
|
1089
1104
|
- lib/openc3/models/python_package_model.rb
|
1105
|
+
- lib/openc3/models/queue_model.rb
|
1090
1106
|
- lib/openc3/models/reaction_model.rb
|
1091
1107
|
- lib/openc3/models/reducer_model.rb
|
1092
1108
|
- lib/openc3/models/router_model.rb
|
@@ -1145,6 +1161,7 @@ files:
|
|
1145
1161
|
- lib/openc3/script/metadata.rb
|
1146
1162
|
- lib/openc3/script/packages.rb
|
1147
1163
|
- lib/openc3/script/plugins.rb
|
1164
|
+
- lib/openc3/script/queue.rb
|
1148
1165
|
- lib/openc3/script/screen.rb
|
1149
1166
|
- lib/openc3/script/script.rb
|
1150
1167
|
- lib/openc3/script/script_runner.rb
|
@@ -1183,6 +1200,7 @@ files:
|
|
1183
1200
|
- lib/openc3/topics/decom_interface_topic.rb
|
1184
1201
|
- lib/openc3/topics/interface_topic.rb
|
1185
1202
|
- lib/openc3/topics/limits_event_topic.rb
|
1203
|
+
- lib/openc3/topics/queue_topic.rb
|
1186
1204
|
- lib/openc3/topics/router_topic.rb
|
1187
1205
|
- lib/openc3/topics/system_events_topic.rb
|
1188
1206
|
- lib/openc3/topics/telemetry_decom_topic.rb
|
@@ -1351,7 +1369,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
1351
1369
|
- !ruby/object:Gem::Version
|
1352
1370
|
version: '0'
|
1353
1371
|
requirements: []
|
1354
|
-
rubygems_version: 3.7.
|
1372
|
+
rubygems_version: 3.7.2
|
1355
1373
|
specification_version: 4
|
1356
1374
|
summary: OpenC3
|
1357
1375
|
test_files: []
|