openc3 7.0.0 → 7.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/bin/openc3cli +58 -10
- data/bin/pipinstall +38 -6
- data/data/config/command_modifiers.yaml +1 -0
- data/data/config/item_modifiers.yaml +2 -1
- data/data/config/table_parameter_modifiers.yaml +3 -1
- data/lib/openc3/accessors/template_accessor.rb +9 -0
- data/lib/openc3/interfaces/interface.rb +1 -6
- data/lib/openc3/microservices/decom_microservice.rb +1 -1
- data/lib/openc3/microservices/interface_decom_common.rb +22 -8
- data/lib/openc3/microservices/interface_microservice.rb +6 -1
- data/lib/openc3/models/plugin_model.rb +9 -1
- data/lib/openc3/models/python_package_model.rb +1 -1
- data/lib/openc3/models/reaction_model.rb +27 -9
- data/lib/openc3/models/trigger_model.rb +24 -7
- data/lib/openc3/script/api_shared.rb +39 -2
- data/lib/openc3/script/calendar.rb +32 -10
- data/lib/openc3/script/extract.rb +46 -13
- data/lib/openc3/topics/decom_interface_topic.rb +19 -4
- data/lib/openc3/topics/interface_topic.rb +21 -2
- data/lib/openc3/utilities/ctrf.rb +231 -0
- data/lib/openc3/utilities/questdb_client.rb +4 -3
- 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 -4
- data/templates/widget/package.json +2 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36f12f777f56b7d64a19cfd89b8886352b6f0fb06bbda59d95d5643b8d4c71f6
|
|
4
|
+
data.tar.gz: 1ebffbb1399279e391a47dde42e6f8b9492be10b389c8e63d148aab402ee614c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ee971acf5bfd378f5a238263b467385286997900bea7fe549b8458c707568fb3a2265b261ee8794601bd42db7d0c2e8a983cf3d81a52c0c5069b9be0b41c4966
|
|
7
|
+
data.tar.gz: 5ba0ea7fb684a6e3e029864289612975db9555d1754f30f65baf4cf3193208516ea74029bb889b4575014b7953c45990c7e515dcf8f43d292bf8fc844de15a6b
|
data/bin/openc3cli
CHANGED
|
@@ -75,6 +75,7 @@ def print_usage
|
|
|
75
75
|
puts " cli pkguninstall PKGFILENAME SCOPE # Uninstall loaded package (Ruby gem or python package)"
|
|
76
76
|
puts " cli xtce_converter # Convert to and from the XTCE format. Run with --help for more info."
|
|
77
77
|
puts " cli cstol_converter # Converts CSTOL files (.prc) to COSMOS. Run with --help for more info."
|
|
78
|
+
puts " cli setpassword # Set the initial password from OPENC3_API_PASSWORD env var"
|
|
78
79
|
puts ""
|
|
79
80
|
end
|
|
80
81
|
|
|
@@ -588,35 +589,44 @@ def run_bridge(filename, params)
|
|
|
588
589
|
end
|
|
589
590
|
end
|
|
590
591
|
|
|
591
|
-
def cli_script_monitor(script_id)
|
|
592
|
+
def cli_script_monitor(script_id, format: 'text')
|
|
592
593
|
ret_code = ERROR_CODE
|
|
593
594
|
require 'openc3/script'
|
|
595
|
+
require 'openc3/utilities/ctrf'
|
|
594
596
|
begin
|
|
595
597
|
OpenC3::RunningScriptWebSocketApi.new(id: script_id) do |api|
|
|
596
598
|
while (resp = api.read) do
|
|
597
599
|
# see ScriptRunner.vue for types and states
|
|
598
600
|
case resp['type']
|
|
599
601
|
when 'file'
|
|
600
|
-
puts "Filename #{resp['filename']} scope #{resp['scope']}"
|
|
602
|
+
puts "Filename #{resp['filename']} scope #{resp['scope']}" unless format == 'ctrf'
|
|
601
603
|
when 'line'
|
|
602
604
|
fn = resp['filename'].nil? ? '<no file>' : resp['filename']
|
|
603
|
-
puts "At [#{fn}:#{resp['line_no']}] state [#{resp['state']}]"
|
|
605
|
+
puts "At [#{fn}:#{resp['line_no']}] state [#{resp['state']}]" unless format == 'ctrf'
|
|
604
606
|
if resp['state'] == 'error' or resp['state'] == 'crashed'
|
|
605
607
|
$script_interrupt_text = ''
|
|
606
|
-
puts 'script failed'
|
|
608
|
+
puts 'script failed' unless format == 'ctrf'
|
|
607
609
|
break
|
|
608
610
|
end
|
|
609
611
|
when 'output'
|
|
610
|
-
puts resp['line']
|
|
612
|
+
puts resp['line'] unless format == 'ctrf'
|
|
611
613
|
when 'complete'
|
|
612
614
|
$script_interrupt_text = ''
|
|
613
|
-
|
|
615
|
+
if resp['report']
|
|
616
|
+
if format == 'ctrf'
|
|
617
|
+
ctrf_data = OpenC3::Ctrf.convert_report(resp['report'])
|
|
618
|
+
puts JSON.pretty_generate(ctrf_data)
|
|
619
|
+
else
|
|
620
|
+
puts resp['report']
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
puts 'script complete' unless format == 'ctrf'
|
|
614
624
|
ret_code = 0
|
|
615
625
|
break
|
|
616
626
|
# These conditions are all handled by the else
|
|
617
627
|
# when 'running', 'breakpoint', 'waiting', 'time'
|
|
618
628
|
else
|
|
619
|
-
puts resp.pretty_inspect
|
|
629
|
+
puts resp.pretty_inspect unless format == 'ctrf'
|
|
620
630
|
end
|
|
621
631
|
end
|
|
622
632
|
end
|
|
@@ -692,10 +702,10 @@ def cli_script_run(args, options)
|
|
|
692
702
|
puts id
|
|
693
703
|
$script_interrupt_text = " Script #{args[1]} still running remotely.\n" # for Ctrl-C
|
|
694
704
|
if (options[:wait] < 1) then
|
|
695
|
-
ret_code = cli_script_monitor(id)
|
|
705
|
+
ret_code = cli_script_monitor(id, format: options[:format])
|
|
696
706
|
else
|
|
697
707
|
Timeout::timeout(options[:wait], nil, "--wait #{options[:wait]} exceeded") do
|
|
698
|
-
ret_code = cli_script_monitor(id)
|
|
708
|
+
ret_code = cli_script_monitor(id, format: options[:format])
|
|
699
709
|
rescue Timeout::ExitException, Timeout::Error => e
|
|
700
710
|
# Timeout exceptions are also raised by the Websocket API, so we check
|
|
701
711
|
if e.message =~ /^--wait /
|
|
@@ -795,7 +805,7 @@ rescue => e
|
|
|
795
805
|
end
|
|
796
806
|
|
|
797
807
|
def cli_script(args=[])
|
|
798
|
-
options = {scope: 'DEFAULT', disconnect: false, wait: 0, verbose: false}
|
|
808
|
+
options = {scope: 'DEFAULT', disconnect: false, wait: 0, verbose: false, format: 'text'}
|
|
799
809
|
option_parser = OptionParser.new do |opts|
|
|
800
810
|
opts.banner = "Usage: script --scope SCOPE [init | list | spawn | run]\n" +
|
|
801
811
|
" init Initialize running scripts (Enterprise Only)\n" +
|
|
@@ -812,6 +822,9 @@ def cli_script(args=[])
|
|
|
812
822
|
opts.on("--scope SCOPE", "Run with specified scope (default = DEFAULT)") do |arg|
|
|
813
823
|
options[:scope] = arg
|
|
814
824
|
end
|
|
825
|
+
opts.on("--format FORMAT", "Output format: text or ctrf (default = text)") do |arg|
|
|
826
|
+
options[:format] = arg
|
|
827
|
+
end
|
|
815
828
|
opts.on("--suite SUITE", "Run with specified suite") do |arg|
|
|
816
829
|
options[:suite] = arg
|
|
817
830
|
end
|
|
@@ -892,6 +905,25 @@ def cli_script(args=[])
|
|
|
892
905
|
exit(ret_code)
|
|
893
906
|
end
|
|
894
907
|
|
|
908
|
+
def set_password
|
|
909
|
+
password = ENV['OPENC3_API_PASSWORD']
|
|
910
|
+
argon2_profile = ENV["OPENC3_ARGON2_PROFILE"]&.to_sym || :rfc_9106_low_memory
|
|
911
|
+
if password.nil? or password.empty?
|
|
912
|
+
abort "OPENC3_API_PASSWORD environment variable is required"
|
|
913
|
+
end
|
|
914
|
+
if password.length < 8
|
|
915
|
+
abort "Password must be at least 8 characters"
|
|
916
|
+
end
|
|
917
|
+
redis = Redis.new(url: $redis_url, username: ENV['OPENC3_REDIS_USERNAME'], password: ENV['OPENC3_REDIS_PASSWORD'])
|
|
918
|
+
if redis.exists('OPENC3__TOKEN') == 1
|
|
919
|
+
abort "Password has already been set. Use the web interface to change the password."
|
|
920
|
+
end
|
|
921
|
+
pw_hash = Argon2::Password.create(password, profile: argon2_profile)
|
|
922
|
+
redis.set('OPENC3__TOKEN', pw_hash)
|
|
923
|
+
puts "Password set successfully."
|
|
924
|
+
exit 0
|
|
925
|
+
end
|
|
926
|
+
|
|
895
927
|
def migrate_password_hash
|
|
896
928
|
password = ENV['OPENC3_API_PASSWORD']
|
|
897
929
|
argon2_profile = ENV["OPENC3_ARGON2_PROFILE"]&.to_sym || :rfc_9106_low_memory
|
|
@@ -1377,6 +1409,22 @@ if not ARGV[0].nil? # argument(s) given
|
|
|
1377
1409
|
end
|
|
1378
1410
|
run_migrations(ARGV[1])
|
|
1379
1411
|
|
|
1412
|
+
when 'setpassword'
|
|
1413
|
+
if ARGV[1] == '--help' || ARGV[1] == '-h'
|
|
1414
|
+
puts "Usage: cli setpassword"
|
|
1415
|
+
puts ""
|
|
1416
|
+
puts "Set the initial COSMOS password from the OPENC3_API_PASSWORD environment variable."
|
|
1417
|
+
puts "This allows you to skip the password creation screen in the web interface."
|
|
1418
|
+
puts ""
|
|
1419
|
+
puts "The password must be at least 8 characters. This command will fail if a"
|
|
1420
|
+
puts "password has already been set."
|
|
1421
|
+
puts ""
|
|
1422
|
+
puts "Options:"
|
|
1423
|
+
puts " -h, --help Show this help message"
|
|
1424
|
+
exit 0
|
|
1425
|
+
end
|
|
1426
|
+
set_password()
|
|
1427
|
+
|
|
1380
1428
|
when 'migratepassword'
|
|
1381
1429
|
migrate_password_hash()
|
|
1382
1430
|
|
data/bin/pipinstall
CHANGED
|
@@ -1,13 +1,45 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
uv venv "$PYTHONUSERBASE"
|
|
2
|
+
uv venv "$PYTHONUSERBASE" --allow-existing
|
|
3
3
|
echo "uv pip install $@"
|
|
4
4
|
uv pip install --python "$PYTHONUSERBASE" "$@"
|
|
5
5
|
if [ $? -eq 0 ]; then
|
|
6
6
|
echo "Command succeeded"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
exit 0
|
|
8
|
+
fi
|
|
9
|
+
|
|
10
|
+
# Collect the last arg and all preceding args
|
|
11
|
+
LAST_ARG=""
|
|
12
|
+
OPTS=""
|
|
13
|
+
PREV=""
|
|
14
|
+
for ARG in "$@"; do
|
|
15
|
+
if [ -n "$PREV" ]; then
|
|
16
|
+
OPTS="${OPTS} ${PREV}"
|
|
17
|
+
fi
|
|
18
|
+
PREV="$ARG"
|
|
19
|
+
done
|
|
20
|
+
LAST_ARG="$PREV"
|
|
21
|
+
|
|
22
|
+
# If last arg is a directory with pyproject.toml, the build may have failed
|
|
23
|
+
# (e.g. Poetry package-mode = false). Try compiling and installing declared
|
|
24
|
+
# dependencies only, without attempting to build the package itself.
|
|
25
|
+
if [ -d "$LAST_ARG" ] && [ -f "$LAST_ARG/pyproject.toml" ]; then
|
|
26
|
+
echo "Warning: Failed to build Python package, attempting to install declared dependencies from pyproject.toml"
|
|
27
|
+
TMPFILE=$(mktemp)
|
|
28
|
+
uv pip compile ${OPTS} "${LAST_ARG}/pyproject.toml" > "$TMPFILE"
|
|
29
|
+
if [ $? -eq 0 ] && [ -s "$TMPFILE" ]; then
|
|
30
|
+
uv pip install --python "$PYTHONUSERBASE" ${OPTS} -r "$TMPFILE"
|
|
31
|
+
if [ $? -eq 0 ]; then
|
|
32
|
+
echo "Dependencies installed successfully"
|
|
33
|
+
rm -f "$TMPFILE"
|
|
34
|
+
exit 0
|
|
35
|
+
fi
|
|
12
36
|
fi
|
|
37
|
+
rm -f "$TMPFILE"
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
echo "Warning: Install failed - retrying with --no-index"
|
|
41
|
+
uv pip install --python "$PYTHONUSERBASE" --no-index "$@"
|
|
42
|
+
if [ $? -ne 0 ]; then
|
|
43
|
+
echo "ERROR: uv pip install failed"
|
|
44
|
+
exit 1
|
|
13
45
|
fi
|
|
@@ -199,6 +199,7 @@ HIDDEN:
|
|
|
199
199
|
summary: Hides this command from all OpenC3 tools such as Command Sender and Handbook Creator
|
|
200
200
|
description: Hidden commands do not appear in the Script Runner popup helper when writing scripts.
|
|
201
201
|
The command still exists in the system and can be sent by scripts.
|
|
202
|
+
since: 6.10.1
|
|
202
203
|
DISABLED:
|
|
203
204
|
summary: Disables this command from being sent
|
|
204
205
|
description: Hides the command and also disables it from being sent by scripts.
|
|
@@ -192,4 +192,5 @@ HIDDEN:
|
|
|
192
192
|
summary: Hides this item from all the OpenC3 tools
|
|
193
193
|
description: This item will not appear in PacketViewer or Item Choosers.
|
|
194
194
|
It also hides this item from appearing in the Script Runner popup helper
|
|
195
|
-
when writing scripts. The item will also not be included in decom data.
|
|
195
|
+
when writing scripts. The item will also not be included in decom data.
|
|
196
|
+
since: 6.10.1
|
|
@@ -3,7 +3,9 @@ HIDDEN:
|
|
|
3
3
|
summary: Indicates that the parameter should not be shown to the user in the Table Manager GUI
|
|
4
4
|
description: Hidden parameters still exist and will be saved to the resulting
|
|
5
5
|
binary. This is useful for padding and other essential but non-user editable fields.
|
|
6
|
+
since: 6.10.1
|
|
6
7
|
UNEDITABLE:
|
|
7
8
|
summary: Indicates that the parameter should be shown to the user but not editable.
|
|
8
|
-
description:
|
|
9
|
+
description:
|
|
10
|
+
Uneditable parameters are useful for control fields which the user
|
|
9
11
|
may be interested in but should not be able to edit.
|
|
@@ -53,6 +53,9 @@ module OpenC3
|
|
|
53
53
|
return nil if item.data_type == :DERIVED
|
|
54
54
|
configure()
|
|
55
55
|
|
|
56
|
+
# No template items to read (e.g. command with fixed template string)
|
|
57
|
+
return nil if @item_keys.empty?
|
|
58
|
+
|
|
56
59
|
# Scan the response for all the variables in brackets <VARIABLE>
|
|
57
60
|
values = buffer.scan(@read_regexp)[0]
|
|
58
61
|
if !values || (values.length != @item_keys.length)
|
|
@@ -73,6 +76,12 @@ module OpenC3
|
|
|
73
76
|
result = {}
|
|
74
77
|
configure()
|
|
75
78
|
|
|
79
|
+
# No template items to read (e.g. command with fixed template string)
|
|
80
|
+
if @item_keys.empty?
|
|
81
|
+
items.each { |item| result[item.name] = nil }
|
|
82
|
+
return result
|
|
83
|
+
end
|
|
84
|
+
|
|
76
85
|
# Scan the response for all the variables in brackets <VARIABLE>
|
|
77
86
|
values = buffer.scan(@read_regexp)[0]
|
|
78
87
|
if !values || (values.length != @item_keys.length)
|
|
@@ -295,17 +295,12 @@ module OpenC3
|
|
|
295
295
|
else
|
|
296
296
|
data, extra = protocol.read_data(data)
|
|
297
297
|
end
|
|
298
|
-
protocol.read_protocol_output_base(data, extra) unless blank_test
|
|
298
|
+
protocol.read_protocol_output_base(data, extra) unless blank_test or data == :STOP or data == :DISCONNECT
|
|
299
299
|
if data == :DISCONNECT
|
|
300
300
|
Logger.info("#{@name}: Protocol #{protocol.class} read_data requested disconnect")
|
|
301
301
|
return nil
|
|
302
302
|
end
|
|
303
303
|
break if data == :STOP
|
|
304
|
-
if blank_test
|
|
305
|
-
# This means the blank test returned something so we can log
|
|
306
|
-
protocol.read_protocol_input_base('', nil)
|
|
307
|
-
protocol.read_protocol_output_base(data, extra)
|
|
308
|
-
end
|
|
309
304
|
end
|
|
310
305
|
next if data == :STOP
|
|
311
306
|
|
|
@@ -116,7 +116,7 @@ module OpenC3
|
|
|
116
116
|
microservice_cmd(topic, msg_id, msg_hash, redis)
|
|
117
117
|
elsif topic =~ /__DECOMINTERFACE/
|
|
118
118
|
if msg_hash.key?('inject_tlm')
|
|
119
|
-
|
|
119
|
+
handle_inject_tlm_with_ack(msg_hash['inject_tlm'], msg_id)
|
|
120
120
|
next
|
|
121
121
|
end
|
|
122
122
|
if msg_hash.key?('build_cmd')
|
|
@@ -34,10 +34,26 @@ module OpenC3
|
|
|
34
34
|
packet.received_time = Time.now.sys
|
|
35
35
|
packet.received_count = TargetModel.increment_telemetry_count(packet.target_name, packet.packet_name, 1, scope: @scope)
|
|
36
36
|
TelemetryTopic.write_packet(packet, scope: @scope)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def handle_inject_tlm_with_ack(inject_tlm_json, msg_id)
|
|
40
|
+
inject_tlm_hash = JSON.parse(inject_tlm_json, allow_nan: true, create_additions: true)
|
|
41
|
+
target_name = inject_tlm_hash['target_name']
|
|
42
|
+
ack_topic = "{#{@scope}__ACKCMD}TARGET__#{target_name}"
|
|
43
|
+
begin
|
|
44
|
+
handle_inject_tlm(inject_tlm_json)
|
|
45
|
+
msg_hash = {
|
|
46
|
+
id: msg_id,
|
|
47
|
+
result: 'SUCCESS'
|
|
48
|
+
}
|
|
49
|
+
rescue => error
|
|
50
|
+
@logger.error "inject_tlm error due to #{error.message}"
|
|
51
|
+
msg_hash = {
|
|
52
|
+
id: msg_id,
|
|
53
|
+
result: error.message
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
Topic.write_topic(ack_topic, msg_hash)
|
|
41
57
|
end
|
|
42
58
|
|
|
43
59
|
def handle_build_cmd(build_cmd_json, msg_id)
|
|
@@ -65,8 +81,7 @@ module OpenC3
|
|
|
65
81
|
rescue => error
|
|
66
82
|
msg_hash = {
|
|
67
83
|
id: msg_id,
|
|
68
|
-
result:
|
|
69
|
-
message: error.message
|
|
84
|
+
result: error.message
|
|
70
85
|
}
|
|
71
86
|
end
|
|
72
87
|
Topic.write_topic(ack_topic, msg_hash)
|
|
@@ -97,8 +112,7 @@ module OpenC3
|
|
|
97
112
|
rescue => error
|
|
98
113
|
msg_hash = {
|
|
99
114
|
id: msg_id,
|
|
100
|
-
result:
|
|
101
|
-
message: error.message
|
|
115
|
+
result: error.message
|
|
102
116
|
}
|
|
103
117
|
end
|
|
104
118
|
Topic.write_topic(ack_topic, msg_hash)
|
|
@@ -179,7 +179,12 @@ module OpenC3
|
|
|
179
179
|
next 'SUCCESS'
|
|
180
180
|
end
|
|
181
181
|
if msg_hash.key?('inject_tlm')
|
|
182
|
-
|
|
182
|
+
begin
|
|
183
|
+
handle_inject_tlm(msg_hash['inject_tlm'])
|
|
184
|
+
rescue => e
|
|
185
|
+
@logger.error "#{@interface.name}: inject_tlm: #{e.formatted}"
|
|
186
|
+
next e.message
|
|
187
|
+
end
|
|
183
188
|
next 'SUCCESS'
|
|
184
189
|
end
|
|
185
190
|
if msg_hash.key?('release_critical')
|
|
@@ -280,7 +280,15 @@ module OpenC3
|
|
|
280
280
|
pip_args = "-i #{pypi_url} --trusted-host #{URI.parse(pypi_url).host} -r #{requirements_path}"
|
|
281
281
|
end
|
|
282
282
|
end
|
|
283
|
-
|
|
283
|
+
# Capture output and check exit code so failures surface as a warning
|
|
284
|
+
# rather than silently succeeding. pipinstall is non-fatal: the plugin
|
|
285
|
+
# continues to install even if Python packages fail so that non-Python
|
|
286
|
+
# functionality still works.
|
|
287
|
+
output = `/openc3/bin/pipinstall #{pip_args}`
|
|
288
|
+
puts output
|
|
289
|
+
unless $?.success?
|
|
290
|
+
Logger.warn "Python package installation failed. Plugin Python microservices may not function correctly."
|
|
291
|
+
end
|
|
284
292
|
end
|
|
285
293
|
needs_dependencies = true
|
|
286
294
|
end
|
|
@@ -99,7 +99,7 @@ module OpenC3
|
|
|
99
99
|
def self.destroy(name, scope:)
|
|
100
100
|
package_name, version = self.extract_name_and_version(name)
|
|
101
101
|
Logger.info "Uninstalling package: #{name}"
|
|
102
|
-
pip_args = [
|
|
102
|
+
pip_args = [package_name]
|
|
103
103
|
result = OpenC3::ProcessManager.instance.spawn(["/openc3/bin/pipuninstall"] + pip_args, "package_uninstall", name, Time.now + 3600.0, scope: scope)
|
|
104
104
|
return result.name
|
|
105
105
|
end
|
|
@@ -32,10 +32,12 @@ module OpenC3
|
|
|
32
32
|
ACTION_TYPES = [SCRIPT_REACTION, COMMAND_REACTION, NOTIFY_REACTION]
|
|
33
33
|
|
|
34
34
|
def self.create_unique_name(scope:)
|
|
35
|
-
reaction_names = self.names(scope: scope)
|
|
35
|
+
reaction_names = self.names(scope: scope)
|
|
36
36
|
num = 1 # Users count with 1
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
unless reaction_names.empty?
|
|
38
|
+
# Extract numeric suffixes and find the max to avoid lexicographic sort issues
|
|
39
|
+
max_num = reaction_names.map { |name| name[5..-1].to_i }.max
|
|
40
|
+
num = max_num + 1
|
|
39
41
|
end
|
|
40
42
|
return "REACT#{num}"
|
|
41
43
|
end
|
|
@@ -77,7 +79,7 @@ module OpenC3
|
|
|
77
79
|
end
|
|
78
80
|
|
|
79
81
|
attr_reader :name, :scope, :snooze, :triggers, :actions, :enabled, :trigger_level, :snoozed_until
|
|
80
|
-
attr_accessor :username, :shard
|
|
82
|
+
attr_accessor :username, :shard, :label
|
|
81
83
|
|
|
82
84
|
def initialize(
|
|
83
85
|
name:,
|
|
@@ -90,6 +92,7 @@ module OpenC3
|
|
|
90
92
|
snoozed_until: nil,
|
|
91
93
|
username: nil,
|
|
92
94
|
shard: 0,
|
|
95
|
+
label: nil,
|
|
93
96
|
updated_at: nil
|
|
94
97
|
)
|
|
95
98
|
super("#{scope}#{PRIMARY_KEY}", name: name, scope: scope)
|
|
@@ -102,6 +105,7 @@ module OpenC3
|
|
|
102
105
|
@triggers = validate_triggers(triggers)
|
|
103
106
|
@username = username
|
|
104
107
|
@shard = shard.to_i # to_i to handle nil
|
|
108
|
+
@label = label
|
|
105
109
|
@updated_at = updated_at
|
|
106
110
|
end
|
|
107
111
|
|
|
@@ -177,28 +181,40 @@ module OpenC3
|
|
|
177
181
|
return actions
|
|
178
182
|
end
|
|
179
183
|
|
|
180
|
-
|
|
184
|
+
# Validate that all triggers exist, but do not persist dependent changes yet.
|
|
185
|
+
# Returns the list of trigger models that need updating.
|
|
186
|
+
def validate_triggers_exist
|
|
181
187
|
if @triggers.empty?
|
|
182
188
|
raise ReactionInputError.new "reaction must contain at least one valid trigger: #{@triggers}"
|
|
183
189
|
end
|
|
184
190
|
|
|
191
|
+
models_to_update = []
|
|
185
192
|
@triggers.each do | trigger |
|
|
186
193
|
model = TriggerModel.get(name: trigger['name'], group: trigger['group'], scope: @scope)
|
|
187
194
|
if model.nil?
|
|
188
195
|
raise ReactionInputError.new "failed to find trigger: #{trigger}"
|
|
189
196
|
end
|
|
190
|
-
model.
|
|
191
|
-
|
|
197
|
+
unless model.dependents.include?(@name)
|
|
198
|
+
model.update_dependents(dependent: @name)
|
|
199
|
+
models_to_update << model
|
|
200
|
+
end
|
|
192
201
|
end
|
|
202
|
+
models_to_update
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Persist dependent changes to trigger models
|
|
206
|
+
def commit_trigger_dependents(models)
|
|
207
|
+
models.each { |model| model.update() }
|
|
193
208
|
end
|
|
194
209
|
|
|
195
210
|
def create
|
|
196
211
|
unless Store.hget(@primary_key, @name).nil?
|
|
197
212
|
raise ReactionInputError.new "existing reaction found: #{@name}"
|
|
198
213
|
end
|
|
199
|
-
|
|
214
|
+
models = validate_triggers_exist()
|
|
200
215
|
@updated_at = Time.now.to_nsec_from_epoch
|
|
201
216
|
Store.hset(@primary_key, @name, JSON.generate(as_json, allow_nan: true))
|
|
217
|
+
commit_trigger_dependents(models)
|
|
202
218
|
notify(kind: 'created')
|
|
203
219
|
end
|
|
204
220
|
|
|
@@ -222,9 +238,10 @@ module OpenC3
|
|
|
222
238
|
end
|
|
223
239
|
end
|
|
224
240
|
|
|
225
|
-
|
|
241
|
+
models = validate_triggers_exist()
|
|
226
242
|
@updated_at = Time.now.to_nsec_from_epoch
|
|
227
243
|
Store.hset(@primary_key, @name, JSON.generate(as_json, allow_nan: true))
|
|
244
|
+
commit_trigger_dependents(models)
|
|
228
245
|
# No notification as this is only called via reaction_controller which already notifies
|
|
229
246
|
end
|
|
230
247
|
|
|
@@ -281,6 +298,7 @@ module OpenC3
|
|
|
281
298
|
'actions' => @actions,
|
|
282
299
|
'username' => @username,
|
|
283
300
|
'shard' => @shard,
|
|
301
|
+
'label' => @label,
|
|
284
302
|
'updated_at' => @updated_at
|
|
285
303
|
}
|
|
286
304
|
end
|
|
@@ -52,10 +52,12 @@ module OpenC3
|
|
|
52
52
|
TRIGGER_TYPE = 'trigger'.freeze
|
|
53
53
|
|
|
54
54
|
def self.create_unique_name(group:, scope:)
|
|
55
|
-
trigger_names = self.names(group: group, scope: scope)
|
|
55
|
+
trigger_names = self.names(group: group, scope: scope)
|
|
56
56
|
num = 1 # Users count with 1
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
unless trigger_names.empty?
|
|
58
|
+
# Extract numeric suffixes and find the max to avoid lexicographic sort issues
|
|
59
|
+
max_num = trigger_names.map { |name| name[4..-1].to_i }.max
|
|
60
|
+
num = max_num + 1
|
|
59
61
|
end
|
|
60
62
|
return "TRIG#{num}"
|
|
61
63
|
end
|
|
@@ -97,6 +99,7 @@ module OpenC3
|
|
|
97
99
|
end
|
|
98
100
|
|
|
99
101
|
attr_reader :name, :scope, :state, :group, :enabled, :left, :operator, :right, :dependents, :roots
|
|
102
|
+
attr_accessor :label
|
|
100
103
|
|
|
101
104
|
def initialize(
|
|
102
105
|
name:,
|
|
@@ -108,6 +111,7 @@ module OpenC3
|
|
|
108
111
|
state: false,
|
|
109
112
|
enabled: true,
|
|
110
113
|
dependents: nil,
|
|
114
|
+
label: nil,
|
|
111
115
|
updated_at: nil
|
|
112
116
|
)
|
|
113
117
|
super("#{scope}#{PRIMARY_KEY}#{group}", name: name, scope: scope)
|
|
@@ -119,6 +123,7 @@ module OpenC3
|
|
|
119
123
|
@operator = validate_operator(operator: operator)
|
|
120
124
|
@right = validate_operand(operand: right, right: true)
|
|
121
125
|
@dependents = dependents
|
|
126
|
+
@label = label
|
|
122
127
|
@updated_at = updated_at
|
|
123
128
|
selected_group = TriggerGroupModel.get(name: @group, scope: @scope)
|
|
124
129
|
if selected_group.nil?
|
|
@@ -175,8 +180,11 @@ module OpenC3
|
|
|
175
180
|
end
|
|
176
181
|
end
|
|
177
182
|
|
|
178
|
-
|
|
183
|
+
# Validate that all root triggers exist, but do not persist dependent changes yet.
|
|
184
|
+
# Returns the list of root trigger models that need updating.
|
|
185
|
+
def validate_roots
|
|
179
186
|
@dependents = [] if @dependents.nil?
|
|
187
|
+
models_to_update = []
|
|
180
188
|
@roots.each do | trigger |
|
|
181
189
|
model = TriggerModel.get(name: trigger, group: @group, scope: @scope)
|
|
182
190
|
if model.nil?
|
|
@@ -184,25 +192,33 @@ module OpenC3
|
|
|
184
192
|
end
|
|
185
193
|
unless model.dependents.include?(@name)
|
|
186
194
|
model.update_dependents(dependent: @name)
|
|
187
|
-
model
|
|
195
|
+
models_to_update << model
|
|
188
196
|
end
|
|
189
197
|
end
|
|
198
|
+
models_to_update
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Persist dependent changes to root triggers
|
|
202
|
+
def commit_roots(models)
|
|
203
|
+
models.each { |model| model.update() }
|
|
190
204
|
end
|
|
191
205
|
|
|
192
206
|
def create
|
|
193
207
|
unless Store.hget(@primary_key, @name).nil?
|
|
194
208
|
raise TriggerInputError.new "existing trigger found: '#{@name}'"
|
|
195
209
|
end
|
|
196
|
-
|
|
210
|
+
models = validate_roots()
|
|
197
211
|
@updated_at = Time.now.to_nsec_from_epoch
|
|
198
212
|
Store.hset(@primary_key, @name, JSON.generate(as_json, allow_nan: true))
|
|
213
|
+
commit_roots(models)
|
|
199
214
|
notify(kind: 'created')
|
|
200
215
|
end
|
|
201
216
|
|
|
202
217
|
def update
|
|
203
|
-
|
|
218
|
+
models = validate_roots()
|
|
204
219
|
@updated_at = Time.now.to_nsec_from_epoch
|
|
205
220
|
Store.hset(@primary_key, @name, JSON.generate(as_json, allow_nan: true))
|
|
221
|
+
commit_roots(models)
|
|
206
222
|
# No notification as this is only called via trigger_controller which already notifies
|
|
207
223
|
end
|
|
208
224
|
|
|
@@ -267,6 +283,7 @@ module OpenC3
|
|
|
267
283
|
'left' => @left,
|
|
268
284
|
'operator' => @operator,
|
|
269
285
|
'right' => @right,
|
|
286
|
+
'label' => @label,
|
|
270
287
|
'updated_at' => @updated_at,
|
|
271
288
|
}
|
|
272
289
|
end
|
|
@@ -288,6 +288,7 @@ module OpenC3
|
|
|
288
288
|
start_time = Time.now.sys
|
|
289
289
|
success, value = _openc3_script_wait_implementation_comparison(target_name, packet_name, item_name, type, comparison_to_eval, timeout, polling_rate, scope: scope, token: token, &block)
|
|
290
290
|
value = "'#{value}'" if value.is_a? String # Show user the check against a quoted string
|
|
291
|
+
value = 'nil' if value.nil? # Show user nil value as 'nil'
|
|
291
292
|
time_diff = Time.now.sys - start_time
|
|
292
293
|
check_str = "CHECK: #{_upcase(target_name, packet_name, item_name)}"
|
|
293
294
|
if comparison_to_eval
|
|
@@ -531,7 +532,7 @@ module OpenC3
|
|
|
531
532
|
if comparison_to_eval
|
|
532
533
|
_check_eval(target_name, packet_name, item_name, comparison_to_eval, value)
|
|
533
534
|
else
|
|
534
|
-
puts "CHECK: #{_upcase(target_name, packet_name, item_name)} == #{value}"
|
|
535
|
+
puts "CHECK: #{_upcase(target_name, packet_name, item_name)} == #{value.nil? ? 'nil' : value.inspect}"
|
|
535
536
|
end
|
|
536
537
|
end
|
|
537
538
|
|
|
@@ -632,6 +633,7 @@ module OpenC3
|
|
|
632
633
|
start_time = Time.now.sys
|
|
633
634
|
success, value = _openc3_script_wait_implementation_comparison(target_name, packet_name, item_name, value_type, comparison_to_eval, timeout, polling_rate, scope: scope, token: token)
|
|
634
635
|
value = "'#{value}'" if value.is_a? String # Show user the check against a quoted string
|
|
636
|
+
value = 'nil' if value.nil? # Show user nil value as 'nil'
|
|
635
637
|
time_diff = Time.now.sys - start_time
|
|
636
638
|
wait_str = "WAIT: #{_upcase(target_name, packet_name, item_name)} #{comparison_to_eval}"
|
|
637
639
|
value_str = "with value == #{value} after waiting #{time_diff} seconds"
|
|
@@ -863,8 +865,20 @@ module OpenC3
|
|
|
863
865
|
# Show user the check against a quoted string
|
|
864
866
|
# Note: We have to preserve the original 'value' variable because we're going to eval against it
|
|
865
867
|
value_str = value.is_a?(String) ? "'#{value}'" : value
|
|
868
|
+
value_str = 'nil' if value.nil? # Show user nil value as 'nil'
|
|
866
869
|
with_value = "with value == #{value_str}"
|
|
867
|
-
|
|
870
|
+
|
|
871
|
+
eval_is_valid = _check_eval_validity(value, comparison_to_eval)
|
|
872
|
+
unless eval_is_valid
|
|
873
|
+
message = "Invalid comparison for types"
|
|
874
|
+
if $disconnect
|
|
875
|
+
puts "ERROR: #{message}"
|
|
876
|
+
else
|
|
877
|
+
raise CheckError, message
|
|
878
|
+
end
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
if eval_is_valid && eval(string)
|
|
868
882
|
puts "#{check_str} success #{with_value}"
|
|
869
883
|
else
|
|
870
884
|
message = "#{check_str} failed #{with_value}"
|
|
@@ -883,5 +897,28 @@ module OpenC3
|
|
|
883
897
|
raise e
|
|
884
898
|
end
|
|
885
899
|
end
|
|
900
|
+
|
|
901
|
+
def _check_eval_validity(value, comparison)
|
|
902
|
+
return true if comparison.nil? || comparison.empty?
|
|
903
|
+
|
|
904
|
+
begin
|
|
905
|
+
operator, operand = extract_operator_and_operand_from_comparison(comparison)
|
|
906
|
+
rescue RuntimeError => e
|
|
907
|
+
if e.message.include?("Unable to parse operand")
|
|
908
|
+
# If we can't parse the operand, let the eval happen anyway
|
|
909
|
+
# It will raise an appropriate error (like NameError for undefined constants)
|
|
910
|
+
return true
|
|
911
|
+
end
|
|
912
|
+
raise # Re-raise invalid operator errors
|
|
913
|
+
rescue JSON::ParserError
|
|
914
|
+
return true
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
if [">=", "<=", ">", "<"].include?(operator)
|
|
918
|
+
return false if value.nil? || operand.nil? || value.is_a?(Array) || operand.is_a?(Array)
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
return true
|
|
922
|
+
end
|
|
886
923
|
end
|
|
887
924
|
end
|
|
@@ -61,20 +61,28 @@ module OpenC3
|
|
|
61
61
|
# @param data [Hash, optional] Additional data to associate with the activity. Defaults to {}. Any activity can provide "username", "notes", and "customTitle". "command", "script", and "reserve" keys are reserves for the corresponding activity kind, with "environment" also available for script activities.
|
|
62
62
|
# @param scope [String, optional] The scope of the activity. Defaults to OPENC3_SCOPE, must correspond to the timeline.
|
|
63
63
|
def create_timeline_activity(name, kind:, start:, stop:, data: {}, scope: $openc3_scope)
|
|
64
|
-
|
|
65
|
-
kinds = %w(command script reserve)
|
|
66
|
-
unless kinds.include?(kind)
|
|
67
|
-
raise "Unknown kind: #{kind}. Must be one of #{kinds.join(', ')}."
|
|
68
|
-
end
|
|
69
|
-
post_data = {}
|
|
70
|
-
post_data['start'] = start.to_datetime.iso8601
|
|
71
|
-
post_data['stop'] = stop.to_datetime.iso8601
|
|
72
|
-
post_data['kind'] = kind
|
|
73
|
-
post_data['data'] = data
|
|
64
|
+
post_data = _build_activity_data(kind, start, stop, data)
|
|
74
65
|
response = $api_server.request('post', "/openc3-api/timeline/#{name}/activities", data: post_data, json: true, scope: scope)
|
|
75
66
|
return _cal_handle_response(response, 'Failed to create timeline activity')
|
|
76
67
|
end
|
|
77
68
|
|
|
69
|
+
# Updates an existing activity on the specified timeline.
|
|
70
|
+
#
|
|
71
|
+
# @param name [String] The name of the timeline.
|
|
72
|
+
# @param id [Integer] The start time / score of the activity (Unix seconds).
|
|
73
|
+
# @param kind [String] The kind of activity. Must be one of "COMMAND", "SCRIPT", or "RESERVE".
|
|
74
|
+
# @param start [DateTime] The new start time of the activity.
|
|
75
|
+
# @param stop [DateTime] The new stop time of the activity.
|
|
76
|
+
# @param data [Hash, optional] Additional data to associate with the activity. Defaults to {}. Any activity can provide "username", "notes", and "customTitle". "command", "script", and "reserve" keys are reserved for the corresponding activity kind, with "environment" also available for script activities.
|
|
77
|
+
# @param uuid [String] The UUID of the activity.
|
|
78
|
+
# @param scope [String, optional] The scope of the activity. Defaults to OPENC3_SCOPE.
|
|
79
|
+
def update_timeline_activity(name, id:, kind:, start:, stop:, uuid:, data: {}, scope: $openc3_scope)
|
|
80
|
+
post_data = _build_activity_data(kind, start, stop, data)
|
|
81
|
+
url = "/openc3-api/timeline/#{name}/activity/#{id}/#{uuid}"
|
|
82
|
+
response = $api_server.request('put', url, data: post_data, json: true, scope: scope)
|
|
83
|
+
return _cal_handle_response(response, 'Failed to update timeline activity')
|
|
84
|
+
end
|
|
85
|
+
|
|
78
86
|
def get_timeline_activity(name, start, uuid, scope: $openc3_scope)
|
|
79
87
|
response = $api_server.request('get', "/openc3-api/timeline/#{name}/activity/#{start}/#{uuid}", scope: scope)
|
|
80
88
|
return _cal_handle_response(response, 'Failed to get timeline activity')
|
|
@@ -97,6 +105,20 @@ module OpenC3
|
|
|
97
105
|
return _cal_handle_response(response, 'Failed to delete timeline activity')
|
|
98
106
|
end
|
|
99
107
|
|
|
108
|
+
def _build_activity_data(kind, start, stop, data)
|
|
109
|
+
kind = kind.to_s.downcase()
|
|
110
|
+
kinds = %w(command script reserve)
|
|
111
|
+
unless kinds.include?(kind)
|
|
112
|
+
raise "Unknown kind: #{kind}. Must be one of #{kinds.join(', ')}."
|
|
113
|
+
end
|
|
114
|
+
{
|
|
115
|
+
'start' => start.to_datetime.iso8601,
|
|
116
|
+
'stop' => stop.to_datetime.iso8601,
|
|
117
|
+
'kind' => kind,
|
|
118
|
+
'data' => data,
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
100
122
|
# Helper method to handle the response
|
|
101
123
|
def _cal_handle_response(response, error_message)
|
|
102
124
|
return nil if response.nil?
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
# This file may also be used under the terms of a commercial license
|
|
16
16
|
# if purchased from OpenC3, Inc.
|
|
17
17
|
|
|
18
|
+
require 'json'
|
|
18
19
|
require 'openc3/utilities/store'
|
|
19
20
|
|
|
20
21
|
module OpenC3
|
|
@@ -154,22 +155,54 @@ module OpenC3
|
|
|
154
155
|
end
|
|
155
156
|
|
|
156
157
|
def extract_fields_from_check_text(text)
|
|
157
|
-
|
|
158
|
-
raise "ERROR: Check improperly specified: #{text}" if
|
|
158
|
+
target_name, packet_name, item_name, comparison = text.split(nil, 4) # Ruby: second split arg is max number of resultant elements
|
|
159
|
+
raise "ERROR: Check improperly specified: #{text}" if item_name.nil?
|
|
159
160
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
raise "ERROR:
|
|
161
|
+
# comparison is either nil, the comparison string, or an empty string.
|
|
162
|
+
# We need it to not be an empty string.
|
|
163
|
+
comparison = nil if comparison&.length == 0
|
|
164
|
+
|
|
165
|
+
operator, _ = comparison&.split(nil, 2)
|
|
166
|
+
raise "ERROR: Use '==' instead of '=' in #{text}" if operator == "="
|
|
167
|
+
|
|
168
|
+
return [target_name, packet_name, item_name, comparison]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Splits `check()` comparison expressions, e.g. "== 'foo bar'" becomes ["==", "foo bar"]
|
|
172
|
+
def extract_operator_and_operand_from_comparison(comparison)
|
|
173
|
+
valid_operators = ["==", "!=", ">=", "<=", ">", "<", "in"]
|
|
166
174
|
|
|
167
|
-
|
|
168
|
-
index = split_string.rindex(item_name)
|
|
169
|
-
comparison_to_eval = split_string[(index + 1)..(split_string.length - 1)].join(" ")
|
|
170
|
-
raise "ERROR: Use '==' instead of '=': #{text}" if split_string[3] == '='
|
|
175
|
+
operator, operand = comparison.split(nil, 2) # Ruby: second split arg is max number of resultant elements
|
|
171
176
|
|
|
172
|
-
|
|
177
|
+
if operand.nil?
|
|
178
|
+
# Don't allow operator without operand
|
|
179
|
+
raise "ERROR: Invalid comparison, must specify an operand: #{comparison}" if !operator.nil?
|
|
180
|
+
return [nil, nil]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
raise "ERROR: Invalid operator: '#{operator}'" unless valid_operators.include?(operator)
|
|
184
|
+
|
|
185
|
+
# Handle string operand: remove surrounding double/single quotes
|
|
186
|
+
if operand.match?(/^(['"])(.*)\1$/m) # Starts with single or double quote, and ends with matching quote
|
|
187
|
+
operand = operand[1..-2]
|
|
188
|
+
return [operator, operand]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Handle other operand types
|
|
192
|
+
if operand == "nil"
|
|
193
|
+
operand = nil
|
|
194
|
+
elsif operand == "false"
|
|
195
|
+
operand = false
|
|
196
|
+
elsif operand == "true"
|
|
197
|
+
operand = true
|
|
198
|
+
else
|
|
199
|
+
begin
|
|
200
|
+
operand = JSON.parse(operand)
|
|
201
|
+
rescue JSON::ParserError
|
|
202
|
+
raise "ERROR: Unable to parse operand: #{operand}"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
return [operator, operand]
|
|
173
206
|
end
|
|
174
207
|
end
|
|
175
208
|
end
|
|
@@ -37,7 +37,7 @@ module OpenC3
|
|
|
37
37
|
if msg_hash["result"] == "SUCCESS"
|
|
38
38
|
return msg_hash
|
|
39
39
|
else
|
|
40
|
-
raise msg_hash["
|
|
40
|
+
raise msg_hash["result"]
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
end
|
|
@@ -45,14 +45,29 @@ module OpenC3
|
|
|
45
45
|
raise "Timeout of #{timeout}s waiting for cmd ack. Does target '#{target_name}' exist?"
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
def self.inject_tlm(target_name, packet_name, item_hash = nil, type: :CONVERTED, scope:)
|
|
48
|
+
def self.inject_tlm(target_name, packet_name, item_hash = nil, type: :CONVERTED, timeout: 5, scope:)
|
|
49
49
|
data = {}
|
|
50
50
|
data['target_name'] = target_name.to_s.upcase
|
|
51
51
|
data['packet_name'] = packet_name.to_s.upcase
|
|
52
52
|
data['item_hash'] = item_hash
|
|
53
53
|
data['type'] = type
|
|
54
|
-
|
|
54
|
+
ack_topic = "{#{scope}__ACKCMD}TARGET__#{target_name}"
|
|
55
|
+
Topic.update_topic_offsets([ack_topic])
|
|
56
|
+
decom_id = Topic.write_topic("#{scope}__DECOMINTERFACE__{#{target_name}}",
|
|
55
57
|
{ 'inject_tlm' => JSON.generate(data, allow_nan: true) }, '*', 100)
|
|
58
|
+
time = Time.now
|
|
59
|
+
while (Time.now - time) < timeout
|
|
60
|
+
Topic.read_topics([ack_topic]) do |_topic, _msg_id, msg_hash, _redis|
|
|
61
|
+
if msg_hash["id"] == decom_id
|
|
62
|
+
if msg_hash["result"] == "SUCCESS"
|
|
63
|
+
return
|
|
64
|
+
else
|
|
65
|
+
raise msg_hash["result"]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
raise "Timeout of #{timeout}s waiting for cmd ack. Does target '#{target_name}' exist?"
|
|
56
71
|
end
|
|
57
72
|
|
|
58
73
|
def self.get_tlm_buffer(target_name, packet_name, timeout: 5, scope:)
|
|
@@ -77,7 +92,7 @@ module OpenC3
|
|
|
77
92
|
end
|
|
78
93
|
return msg_hash
|
|
79
94
|
else
|
|
80
|
-
raise msg_hash["
|
|
95
|
+
raise msg_hash["result"]
|
|
81
96
|
end
|
|
82
97
|
end
|
|
83
98
|
end
|
|
@@ -111,13 +111,32 @@ module OpenC3
|
|
|
111
111
|
Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'protocol_cmd' => JSON.generate(data, allow_nan: true) }, '*', 100)
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
-
def self.inject_tlm(interface_name, target_name, packet_name, item_hash = nil, type: :CONVERTED, scope:)
|
|
114
|
+
def self.inject_tlm(interface_name, target_name, packet_name, item_hash = nil, type: :CONVERTED, timeout: nil, scope:)
|
|
115
|
+
interface_name = interface_name.upcase
|
|
116
|
+
|
|
117
|
+
timeout = COMMAND_ACK_TIMEOUT_S unless timeout
|
|
118
|
+
ack_topic = "{#{scope}__ACKCMD}INTERFACE__#{interface_name}"
|
|
119
|
+
Topic.update_topic_offsets([ack_topic])
|
|
120
|
+
|
|
115
121
|
data = {}
|
|
116
122
|
data['target_name'] = target_name.to_s.upcase
|
|
117
123
|
data['packet_name'] = packet_name.to_s.upcase
|
|
118
124
|
data['item_hash'] = item_hash
|
|
119
125
|
data['type'] = type
|
|
120
|
-
Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'inject_tlm' => JSON.generate(data, allow_nan: true) }, '*', 100)
|
|
126
|
+
cmd_id = Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'inject_tlm' => JSON.generate(data, allow_nan: true) }, '*', 100)
|
|
127
|
+
time = Time.now
|
|
128
|
+
while (Time.now - time) < timeout
|
|
129
|
+
Topic.read_topics([ack_topic]) do |_topic, _msg_id, msg_hash, _redis|
|
|
130
|
+
if msg_hash["id"] == cmd_id
|
|
131
|
+
if msg_hash["result"] == "SUCCESS"
|
|
132
|
+
return
|
|
133
|
+
else
|
|
134
|
+
raise msg_hash["result"]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
raise "Timeout of #{timeout}s waiting for cmd ack"
|
|
121
140
|
end
|
|
122
141
|
|
|
123
142
|
def self.interface_target_enable(interface_name, target_name, cmd_only: false, tlm_only: false, scope:)
|
|
@@ -0,0 +1,231 @@
|
|
|
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/version'
|
|
15
|
+
require 'date'
|
|
16
|
+
|
|
17
|
+
module OpenC3
|
|
18
|
+
# Utility class for converting COSMOS script reports to CTRF (Common Test Report Format)
|
|
19
|
+
# See https://ctrf.io/docs/category/specification
|
|
20
|
+
class Ctrf
|
|
21
|
+
# Convert a COSMOS plain text script report to CTRF JSON format
|
|
22
|
+
# @param report_content [String] Plain text script report
|
|
23
|
+
# @param version [String] Version string to include in CTRF output (defaults to OpenC3::VERSION)
|
|
24
|
+
# @return [Hash] CTRF formatted report as a Ruby hash
|
|
25
|
+
def self.convert_report(report_content, version: OpenC3::VERSION)
|
|
26
|
+
lines = report_content.split("\n")
|
|
27
|
+
tests = []
|
|
28
|
+
summary = {}
|
|
29
|
+
settings = {}
|
|
30
|
+
in_settings = false
|
|
31
|
+
last_result = nil
|
|
32
|
+
in_summary = false
|
|
33
|
+
|
|
34
|
+
lines.each do |line|
|
|
35
|
+
next if line.nil?
|
|
36
|
+
line_clean = line.strip
|
|
37
|
+
|
|
38
|
+
if line_clean == 'Settings:'
|
|
39
|
+
in_settings = true
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if in_settings
|
|
44
|
+
if line_clean.include?('Manual')
|
|
45
|
+
parts = line.split('=')
|
|
46
|
+
settings[:manual] = parts[1].strip if parts[1]
|
|
47
|
+
next
|
|
48
|
+
elsif line_clean.include?('Pause on Error')
|
|
49
|
+
parts = line.split('=')
|
|
50
|
+
settings[:pauseOnError] = parts[1].strip if parts[1]
|
|
51
|
+
next
|
|
52
|
+
elsif line_clean.include?('Continue After Error')
|
|
53
|
+
parts = line.split('=')
|
|
54
|
+
settings[:continueAfterError] = parts[1].strip if parts[1]
|
|
55
|
+
next
|
|
56
|
+
elsif line_clean.include?('Abort After Error')
|
|
57
|
+
parts = line.split('=')
|
|
58
|
+
settings[:abortAfterError] = parts[1].strip if parts[1]
|
|
59
|
+
next
|
|
60
|
+
elsif line_clean.include?('Loop =')
|
|
61
|
+
parts = line.split('=')
|
|
62
|
+
settings[:loop] = parts[1].strip if parts[1]
|
|
63
|
+
next
|
|
64
|
+
elsif line_clean.include?('Break Loop On Error')
|
|
65
|
+
parts = line.split('=')
|
|
66
|
+
settings[:breakLoopOnError] = parts[1].strip if parts[1]
|
|
67
|
+
in_settings = false
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if line_clean == 'Results:'
|
|
73
|
+
last_result = line_clean
|
|
74
|
+
next
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if last_result
|
|
78
|
+
# The first line should always have a timestamp and what it is executing
|
|
79
|
+
# Format: "2026-04-02T19:45:41.228209Z: Executing MySuite:ExampleGroup:script_2"
|
|
80
|
+
if last_result == 'Results:' and line_clean.include?("Executing")
|
|
81
|
+
# Split on first ': ' to separate timestamp from message
|
|
82
|
+
timestamp_and_msg = line_clean.split(': ', 2)
|
|
83
|
+
if timestamp_and_msg.length >= 2
|
|
84
|
+
timestamp = timestamp_and_msg[0]
|
|
85
|
+
begin
|
|
86
|
+
summary[:startTime] = DateTime.parse(timestamp).to_time.to_f * 1000
|
|
87
|
+
rescue Date::Error
|
|
88
|
+
# Skip malformed timestamps
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
last_result = line_clean
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Format: "2026-04-02T19:45:44.041472Z: ExampleGroup:script_2:PASS"
|
|
96
|
+
# Check if line contains a test result (but not Executing or Completed)
|
|
97
|
+
if !line_clean.include?("Executing") && !line_clean.include?("Completed")
|
|
98
|
+
# Try to parse as a test result line
|
|
99
|
+
timestamp_and_msg = line_clean.split(': ', 2)
|
|
100
|
+
if timestamp_and_msg.length >= 2
|
|
101
|
+
result_string = timestamp_and_msg[1]
|
|
102
|
+
# Must have at least 2 colons for group:name:status format
|
|
103
|
+
result_parts = result_string.split(':')
|
|
104
|
+
if result_parts.length >= 3
|
|
105
|
+
# Get start time from last_result - could be Executing line or previous test result
|
|
106
|
+
start_time = nil
|
|
107
|
+
if last_result
|
|
108
|
+
last_timestamp_and_msg = last_result.split(': ', 2)
|
|
109
|
+
if last_timestamp_and_msg.length >= 2
|
|
110
|
+
begin
|
|
111
|
+
start_time = DateTime.parse(last_timestamp_and_msg[0]).to_time.to_f * 1000
|
|
112
|
+
rescue Date::Error
|
|
113
|
+
# Skip malformed timestamps
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Parse current line timestamp
|
|
119
|
+
timestamp = timestamp_and_msg[0]
|
|
120
|
+
begin
|
|
121
|
+
end_time = DateTime.parse(timestamp).to_time.to_f * 1000
|
|
122
|
+
rescue Date::Error
|
|
123
|
+
last_result = line_clean
|
|
124
|
+
next # Skip lines with malformed timestamps
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Parse the test result: ExampleGroup:script_2:PASS
|
|
128
|
+
suite_group = result_parts[0]
|
|
129
|
+
name = result_parts[1]
|
|
130
|
+
status = result_parts[2]
|
|
131
|
+
|
|
132
|
+
format_status = case status
|
|
133
|
+
when 'PASS'
|
|
134
|
+
'passed'
|
|
135
|
+
when 'SKIP'
|
|
136
|
+
'skipped'
|
|
137
|
+
when 'FAIL'
|
|
138
|
+
'failed'
|
|
139
|
+
else
|
|
140
|
+
'unknown'
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
tests << {
|
|
144
|
+
name: "#{suite_group}:#{name}",
|
|
145
|
+
status: format_status,
|
|
146
|
+
duration: start_time ? (end_time - start_time) : 0,
|
|
147
|
+
}
|
|
148
|
+
last_result = line_clean
|
|
149
|
+
next
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Format: "2026-04-02T19:45:44.044982Z: Completed MySuite:ExampleGroup:script_2"
|
|
155
|
+
if line_clean.include?("Completed")
|
|
156
|
+
timestamp_and_msg = line_clean.split(': ', 2)
|
|
157
|
+
if timestamp_and_msg.length >= 2
|
|
158
|
+
timestamp = timestamp_and_msg[0]
|
|
159
|
+
begin
|
|
160
|
+
summary[:stopTime] = DateTime.parse(timestamp).to_time.to_f * 1000
|
|
161
|
+
rescue Date::Error
|
|
162
|
+
# Skip malformed timestamps
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
last_result = nil
|
|
166
|
+
next
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
if line_clean == '--- Test Summary ---'
|
|
171
|
+
in_summary = true
|
|
172
|
+
next
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
if in_summary
|
|
176
|
+
if line_clean.include?("Total Tests")
|
|
177
|
+
parts = line_clean.split(':')
|
|
178
|
+
summary[:total] = parts[1].to_i if parts[1]
|
|
179
|
+
end
|
|
180
|
+
if line_clean.include?("Pass:")
|
|
181
|
+
parts = line_clean.split(':')
|
|
182
|
+
summary[:passed] = parts[1].to_i if parts[1]
|
|
183
|
+
end
|
|
184
|
+
if line_clean.include?("Skip:")
|
|
185
|
+
parts = line_clean.split(':')
|
|
186
|
+
summary[:skipped] = parts[1].to_i if parts[1]
|
|
187
|
+
end
|
|
188
|
+
if line_clean.include?("Fail:")
|
|
189
|
+
parts = line_clean.split(':')
|
|
190
|
+
summary[:failed] = parts[1].to_i if parts[1]
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Build CTRF report
|
|
196
|
+
# See https://ctrf.io/docs/specification/root
|
|
197
|
+
return {
|
|
198
|
+
reportFormat: "CTRF",
|
|
199
|
+
results: {
|
|
200
|
+
# See https://ctrf.io/docs/specification/tool
|
|
201
|
+
tool: {
|
|
202
|
+
name: "COSMOS Script Runner",
|
|
203
|
+
version: version,
|
|
204
|
+
},
|
|
205
|
+
# See https://ctrf.io/docs/specification/summary
|
|
206
|
+
summary: {
|
|
207
|
+
tests: summary[:total],
|
|
208
|
+
passed: summary[:passed],
|
|
209
|
+
failed: summary[:failed],
|
|
210
|
+
pending: 0,
|
|
211
|
+
skipped: summary[:skipped],
|
|
212
|
+
other: 0,
|
|
213
|
+
start: summary[:startTime],
|
|
214
|
+
stop: summary[:stopTime],
|
|
215
|
+
},
|
|
216
|
+
# See https://ctrf.io/docs/specification/tests
|
|
217
|
+
tests: tests,
|
|
218
|
+
# See https://ctrf.io/docs/specification/extra
|
|
219
|
+
extra: {
|
|
220
|
+
manual: settings[:manual],
|
|
221
|
+
pauseOnError: settings[:pauseOnError],
|
|
222
|
+
continueAfterError: settings[:continueAfterError],
|
|
223
|
+
abortAfterError: settings[:abortAfterError],
|
|
224
|
+
loop: settings[:loop],
|
|
225
|
+
breakLoopOnError: settings[:breakLoopOnError],
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -127,14 +127,15 @@ module OpenC3
|
|
|
127
127
|
# - Arrays are JSON-encoded: "[1, 2, 3]" or '["a", "b"]'
|
|
128
128
|
# - Objects/Hashes are JSON-encoded: '{"key": "value"}'
|
|
129
129
|
# - Binary data (BLOCK) is base64-encoded
|
|
130
|
-
# - Large integers (64-bit) are stored as
|
|
130
|
+
# - Large integers (≥64-bit) are stored as VARCHAR strings
|
|
131
131
|
#
|
|
132
132
|
# @param value [Object] The value to decode
|
|
133
133
|
# @param data_type [String] COSMOS data type (INT, UINT, FLOAT, STRING, BLOCK, DERIVED, etc.)
|
|
134
134
|
# @param array_size [Integer, nil] If not nil, indicates this is an array item
|
|
135
135
|
# @return [Object] The decoded value
|
|
136
136
|
def self.decode_value(value, data_type: nil, array_size: nil)
|
|
137
|
-
# Handle BigDecimal values from QuestDB DECIMAL columns
|
|
137
|
+
# Handle BigDecimal values from legacy QuestDB DECIMAL columns
|
|
138
|
+
# (pre-existing tables may still use DECIMAL; new tables use VARCHAR)
|
|
138
139
|
if value.is_a?(BigDecimal)
|
|
139
140
|
return value.to_i if data_type == 'INT' || data_type == 'UINT'
|
|
140
141
|
return value
|
|
@@ -167,7 +168,7 @@ module OpenC3
|
|
|
167
168
|
end
|
|
168
169
|
end
|
|
169
170
|
|
|
170
|
-
# Integer values stored as strings (
|
|
171
|
+
# Integer values stored as VARCHAR strings (≥64-bit integers)
|
|
171
172
|
if data_type == 'INT' || data_type == 'UINT'
|
|
172
173
|
begin
|
|
173
174
|
return Integer(value)
|
data/lib/openc3/version.rb
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# encoding: ascii-8bit
|
|
2
2
|
|
|
3
|
-
OPENC3_VERSION = '7.0.
|
|
3
|
+
OPENC3_VERSION = '7.0.1'
|
|
4
4
|
module OpenC3
|
|
5
5
|
module Version
|
|
6
6
|
MAJOR = '7'
|
|
7
7
|
MINOR = '0'
|
|
8
|
-
PATCH = '
|
|
8
|
+
PATCH = '1'
|
|
9
9
|
OTHER = ''
|
|
10
|
-
BUILD = '
|
|
10
|
+
BUILD = '665de79cef884b8d470b08817fd4a1fb538fb187'
|
|
11
11
|
end
|
|
12
|
-
VERSION = '7.0.
|
|
13
|
-
GEM_VERSION = '7.0.
|
|
12
|
+
VERSION = '7.0.1'
|
|
13
|
+
GEM_VERSION = '7.0.1'
|
|
14
14
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "<%= tool_name %>",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.1",
|
|
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": "7.0.
|
|
26
|
+
"@openc3/js-common": "7.0.1",
|
|
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": "7.0.
|
|
3
|
+
"version": "7.0.1",
|
|
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": "7.0.
|
|
15
|
-
"@openc3/vue-common": "7.0.
|
|
14
|
+
"@openc3/js-common": "7.0.1",
|
|
15
|
+
"@openc3/vue-common": "7.0.1",
|
|
16
16
|
"axios": "^1.7.7",
|
|
17
17
|
"date-fns": "^4.1.0",
|
|
18
18
|
"lodash": "^4.17.21",
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@vitejs/plugin-vue": "^6.0.1",
|
|
24
24
|
"@vue/eslint-config-prettier": "^9.0.0",
|
|
25
|
-
"@vue/test-utils": "^2.4.6",
|
|
26
25
|
"eslint": "^9.16.0",
|
|
27
26
|
"eslint-config-prettier": "^9.1.0",
|
|
28
27
|
"eslint-plugin-prettier": "^5.2.1",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "<%= widget_name %>",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.1",
|
|
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": "7.0.
|
|
11
|
+
"@openc3/vue-common": "7.0.1",
|
|
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: 7.0.
|
|
4
|
+
version: 7.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ryan Melton
|
|
@@ -1245,6 +1245,7 @@ files:
|
|
|
1245
1245
|
- lib/openc3/utilities/cosmos_rails_formatter.rb
|
|
1246
1246
|
- lib/openc3/utilities/crc.rb
|
|
1247
1247
|
- lib/openc3/utilities/csv.rb
|
|
1248
|
+
- lib/openc3/utilities/ctrf.rb
|
|
1248
1249
|
- lib/openc3/utilities/env_helper.rb
|
|
1249
1250
|
- lib/openc3/utilities/local_bucket.rb
|
|
1250
1251
|
- lib/openc3/utilities/local_mode.rb
|