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
@@ -530,7 +530,7 @@ module OpenC3
530
530
 
531
531
  value = yield(target_name, packet_name, item_name)
532
532
  if comparison_to_eval
533
- _check_eval(target_name, packet_name, item_name, comparison_to_eval, value)
533
+ _check_comparison(target_name, packet_name, item_name, comparison_to_eval, value)
534
534
  else
535
535
  puts "CHECK: #{_upcase(target_name, packet_name, item_name)} == #{value.nil? ? 'nil' : value.inspect}"
536
536
  end
@@ -733,28 +733,16 @@ module OpenC3
733
733
  return [target_name, packet_name, item_name, comparison_to_eval, timeout, polling_rate]
734
734
  end
735
735
 
736
- def _openc3_script_wait_implementation(target_name, packet_name, item_name, value_type, timeout, polling_rate, exp_to_eval, scope: $openc3_scope, token: $openc3_token, &block)
736
+ # Waits for the comparison to be true or the timeout to expire.
737
+ # The comparison is a callable which takes the telemetry value and returns true or false.
738
+ # A block passed by the user takes precedence over the comparison.
739
+ def _openc3_script_wait_implementation(target_name, packet_name, item_name, value_type, timeout, polling_rate, comparison, scope: $openc3_scope, token: $openc3_token, &block)
740
+ condition = block || comparison
737
741
  end_time = Time.now.sys + timeout
738
- if exp_to_eval and !exp_to_eval.is_printable?
739
- raise "ERROR: Invalid comparison to non-ascii value"
740
- end
741
742
  while true
742
743
  work_start = Time.now.sys
743
744
  value = tlm(target_name, packet_name, item_name, type: value_type, scope: scope, token: token)
744
- if not block.nil?
745
- if block.call(value)
746
- return true, value
747
- end
748
- else
749
- begin
750
- if eval(exp_to_eval)
751
- return true, value
752
- end
753
- # NoMethodError is raised when the tlm() returns nil and we try to eval the expression
754
- # In this case we just continue and see if eventually we get a good value from tlm()
755
- rescue NoMethodError
756
- end
757
- end
745
+ return true, value if condition and condition.call(value)
758
746
  break if Time.now.sys >= end_time
759
747
 
760
748
  delta = Time.now.sys - work_start
@@ -766,58 +754,62 @@ module OpenC3
766
754
 
767
755
  if canceled
768
756
  value = tlm(target_name, packet_name, item_name, type: value_type, scope: scope, token: token)
769
- if not block.nil?
770
- if block.call(value)
771
- return true, value
772
- else
773
- return false, value
774
- end
757
+ if condition and condition.call(value)
758
+ return true, value
775
759
  else
776
- begin
777
- if eval(exp_to_eval)
778
- return true, value
779
- else
780
- return false, value
781
- end
782
- # NoMethodError is raised when the tlm() returns nil and we try to eval the expression
783
- rescue NoMethodError
784
- return false, value
785
- end
760
+ return false, value
786
761
  end
787
762
  end
788
763
  end
789
764
 
790
765
  return false, value
791
- rescue NameError => e
792
- if e.message =~ /uninitialized constant OpenC3::ApiShared::(\w+)/
793
- new_error = NameError.new("Uninitialized constant #{$1}. Did you mean '#{$1}' as a string?")
794
- new_error.set_backtrace(e.backtrace)
795
- raise new_error
796
- else
797
- raise e
798
- end
766
+ end
767
+
768
+ # Builds a callable which compares a telemetry value against the given comparison string,
769
+ # e.g. "> 1". Returns nil if there is no comparison, e.g. a block based wait_check().
770
+ # Raises if the comparison is invalid, e.g. an unsupported operator or unparsable operand.
771
+ def _comparison_implementation(comparison_to_eval)
772
+ return nil unless comparison_to_eval
773
+
774
+ operator, operand = extract_operator_and_operand_from_comparison(comparison_to_eval)
775
+ return nil unless operator
776
+ lambda { |value| compare_values(value, operator, operand) }
799
777
  end
800
778
 
801
779
  # Wait for a converted telemetry item to pass a comparison
802
780
  def _openc3_script_wait_implementation_comparison(target_name, packet_name, item_name, value_type, comparison_to_eval, timeout, polling_rate = DEFAULT_TLM_POLLING_RATE, scope: $openc3_scope, token: $openc3_token, &block)
803
- if comparison_to_eval
804
- exp_to_eval = "value " + comparison_to_eval
805
- else
806
- exp_to_eval = nil
781
+ # The comparison text is logged whether or not it is parsed so it is always validated
782
+ if comparison_to_eval and !comparison_to_eval.is_printable?
783
+ raise "ERROR: Invalid comparison to non-ascii value"
807
784
  end
808
- _openc3_script_wait_implementation(target_name, packet_name, item_name, value_type, timeout, polling_rate, exp_to_eval, scope: scope, token: token, &block)
785
+ # A user supplied block is the condition so the comparison text is never parsed
786
+ comparison = block ? nil : _comparison_implementation(comparison_to_eval)
787
+ _openc3_script_wait_implementation(target_name, packet_name, item_name, value_type, timeout, polling_rate, comparison, scope: scope, token: token, &block)
809
788
  end
810
789
 
811
790
  def _openc3_script_wait_implementation_tolerance(target_name, packet_name, item_name, value_type, expected_value, tolerance, timeout, polling_rate = DEFAULT_TLM_POLLING_RATE, scope: $openc3_scope, token: $openc3_token, &block)
812
- exp_to_eval = "((#{expected_value} - #{tolerance})..(#{expected_value} + #{tolerance})).include? value"
813
- _openc3_script_wait_implementation(target_name, packet_name, item_name, value_type, timeout, polling_rate, exp_to_eval, scope: scope, token: token, &block)
791
+ comparison = lambda do |value|
792
+ begin
793
+ value >= (expected_value - tolerance) and value <= (expected_value + tolerance)
794
+ rescue ArgumentError, NoMethodError, TypeError
795
+ false
796
+ end
797
+ end
798
+ _openc3_script_wait_implementation(target_name, packet_name, item_name, value_type, timeout, polling_rate, comparison, scope: scope, token: token, &block)
814
799
  end
815
800
 
816
801
  def _openc3_script_wait_implementation_array_tolerance(array_size, target_name, packet_name, item_name, value_type, expected_value, tolerance, timeout, polling_rate = DEFAULT_TLM_POLLING_RATE, scope: $openc3_scope, token: $openc3_token, &block)
817
- statements = []
818
- array_size.times { |i| statements << "(((#{expected_value[i]} - #{tolerance[i]})..(#{expected_value[i]} + #{tolerance[i]})).include? value[#{i}])" }
819
- exp_to_eval = statements.join(" && ")
820
- _openc3_script_wait_implementation(target_name, packet_name, item_name, value_type, timeout, polling_rate, exp_to_eval, scope: scope, token: token, &block)
802
+ comparison = lambda do |values|
803
+ begin
804
+ next false unless values.is_a?(Array) and values.length == array_size
805
+ array_size.times.all? do |i|
806
+ values[i] >= (expected_value[i] - tolerance[i]) and values[i] <= (expected_value[i] + tolerance[i])
807
+ end
808
+ rescue ArgumentError, NoMethodError, TypeError
809
+ false
810
+ end
811
+ end
812
+ _openc3_script_wait_implementation(target_name, packet_name, item_name, value_type, timeout, polling_rate, comparison, scope: scope, token: token, &block)
821
813
  end
822
814
 
823
815
  # Wait on an expression to be true.
@@ -859,17 +851,15 @@ module OpenC3
859
851
  end
860
852
  end
861
853
 
862
- def _check_eval(target_name, packet_name, item_name, comparison_to_eval, value)
863
- string = "value " + comparison_to_eval
854
+ def _check_comparison(target_name, packet_name, item_name, comparison_to_eval, value)
864
855
  check_str = "CHECK: #{_upcase(target_name, packet_name, item_name)} #{comparison_to_eval}"
865
856
  # Show user the check against a quoted string
866
- # Note: We have to preserve the original 'value' variable because we're going to eval against it
867
857
  value_str = value.is_a?(String) ? "'#{value}'" : value
868
858
  value_str = 'nil' if value.nil? # Show user nil value as 'nil'
869
859
  with_value = "with value == #{value_str}"
870
860
 
871
- eval_is_valid = _check_eval_validity(value, comparison_to_eval)
872
- unless eval_is_valid
861
+ operator, operand = extract_operator_and_operand_from_comparison(comparison_to_eval)
862
+ unless _valid_comparison?(value, operator, operand)
873
863
  message = "Invalid comparison for types"
874
864
  if $disconnect
875
865
  puts "ERROR: #{message}"
@@ -878,7 +868,7 @@ module OpenC3
878
868
  end
879
869
  end
880
870
 
881
- if eval_is_valid && eval(string)
871
+ if compare_values(value, operator, operand)
882
872
  puts "#{check_str} success #{with_value}"
883
873
  else
884
874
  message = "#{check_str} failed #{with_value}"
@@ -888,35 +878,19 @@ module OpenC3
888
878
  raise CheckError, message
889
879
  end
890
880
  end
891
- rescue NameError => e
892
- if e.message =~ /uninitialized constant OpenC3::ApiShared::(\w+)/
893
- new_error = NameError.new("Uninitialized constant #{$1}. Did you mean '#{$1}' as a string?")
894
- new_error.set_backtrace(e.backtrace)
895
- raise new_error
896
- else
897
- raise e
898
- end
899
881
  end
900
882
 
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
883
+ # Returns whether the value and operand can be meaningfully compared with the operator.
884
+ # Note this is only used by check() because wait() polls until the value changes.
885
+ def _valid_comparison?(value, operator, operand)
886
+ return true if operator.nil?
916
887
 
917
888
  if [">=", "<=", ">", "<"].include?(operator)
918
- return false if value.nil? || operand.nil? || value.is_a?(Array) || operand.is_a?(Array)
889
+ return false if value.nil? or operand.nil?
890
+ return false if value.is_a?(Array) or operand.is_a?(Array)
891
+ return false if value.is_a?(String) != operand.is_a?(String)
919
892
  end
893
+ # Note 'in' does not need a check here because the parser already requires a list operand
920
894
 
921
895
  return true
922
896
  end
@@ -26,6 +26,37 @@ module OpenC3
26
26
  # whitespace around them is optional.
27
27
  SCANNING_REGULAR_EXPRESSION = %r{ (?:"(?:[^\\"]|\\.)*") | (?:'(?:[^\\']|\\.)*') | (?:\[(?:[^\\\[\]]|\\.|\[(?:[^\\\[\]]|\\.)*\])*\]) | , | [^\s,]+ }x # "
28
28
 
29
+ # Operators supported by check(), wait() and wait_check() comparisons
30
+ COMPARISON_OPERATORS = ["==", "!=", ">=", "<=", ">", "<", "in"]
31
+
32
+ # Single character escape sequences processed inside a double quoted comparison operand.
33
+ # The numeric (\nnn, \xHH, \uHHHH, \u{...}) forms are handled by unescape_double_quoted().
34
+ DOUBLE_QUOTE_ESCAPES = {
35
+ '\\' => "\\", '"' => '"', "'" => "'", '#' => '#', 'n' => "\n", 't' => "\t",
36
+ 'r' => "\r", 'a' => "\a", 'b' => "\b", 'e' => "\e", 'f' => "\f", 'v' => "\v", 's' => ' '
37
+ }
38
+
39
+ # Tokenizes a double quoted operand into escape sequences, interpolation markers and runs
40
+ # of plain characters. The alternatives are ordered longest first so \u{1F600} is not read
41
+ # as \u, and the escape alternatives precede the markers so \#{ is a literal '#{'.
42
+ DOUBLE_QUOTE_TOKEN_REGEX =
43
+ /\\u\{[\h\s]*\}|\\u\h{4}|\\x\h{1,2}|\\[0-7]{1,3}|\\M-\\C-.|\\M-.|\\C-.|\\c.|\\.|\#[{@$]|[^\\\#]+|\#/m
44
+
45
+ # Escape sequences which have a meaning we deliberately do not implement. Dropping the
46
+ # backslash would silently change the value so they are rejected instead.
47
+ UNSUPPORTED_ESCAPE_REGEX = /\A\\(?:c|C-|M-)/
48
+
49
+ # The Ruby string interpolation markers. Interpolation would be code execution so it is
50
+ # rejected rather than silently compared against the uninterpolated text.
51
+ INTERPOLATION_REGEX = /\A\#[{@$]/
52
+
53
+ # Limits Ruby enforces on a \u escape. A codepoint is written with at most six hex digits,
54
+ # the largest character Unicode defines is 0x10FFFF, and the surrogate range is reserved
55
+ # for UTF-16 pairs so it is not a character on its own.
56
+ MAX_UNICODE_DIGITS = 6
57
+ MAX_UNICODE_CODEPOINT = 0x10FFFF
58
+ UNICODE_SURROGATE_RANGE = (0xD800..0xDFFF)
59
+
29
60
  private
30
61
 
31
62
  # Pulls all string keyword arguments into the args array. Raises on any symbol keyword arguments.
@@ -165,8 +196,6 @@ module OpenC3
165
196
 
166
197
  # Splits `check()` comparison expressions, e.g. "== 'foo bar'" becomes ["==", "foo bar"]
167
198
  def extract_operator_and_operand_from_comparison(comparison)
168
- valid_operators = ["==", "!=", ">=", "<=", ">", "<", "in"]
169
-
170
199
  operator, operand = comparison.split(nil, 2) # Ruby: second split arg is max number of resultant elements
171
200
 
172
201
  if operand.nil?
@@ -175,29 +204,196 @@ module OpenC3
175
204
  return [nil, nil]
176
205
  end
177
206
 
178
- raise "ERROR: Invalid operator: '#{operator}'" unless valid_operators.include?(operator)
207
+ raise "ERROR: Invalid operator: '#{operator}'" unless COMPARISON_OPERATORS.include?(operator)
208
+
209
+ # split leaves any trailing whitespace on the operand which the anchored matches reject
210
+ operand = extract_operand(operand.strip)
211
+ # 'in' is containment against a list of values in both Ruby and Python.
212
+ # Enforced here so check(), wait() and wait_check() all reject the same thing.
213
+ if operator == "in" and !operand.is_a?(Array)
214
+ raise "ERROR: The 'in' operator requires a list operand: #{operand.inspect}"
215
+ end
216
+
217
+ return [operator, operand]
218
+ end
179
219
 
180
- # Handle string operand: remove surrounding double/single quotes
181
- if operand.match?(/^(['"])(.*)\1$/m) # Starts with single or double quote, and ends with matching quote
182
- operand = operand[1..-2]
183
- return [operator, operand]
220
+ # Converts the operand of a `check()` comparison expression into a Ruby value.
221
+ # Note this deliberately does not eval the operand so only literal values are supported.
222
+ def extract_operand(operand)
223
+ # A quoted operand must be a single complete string literal. Anything trailing the
224
+ # closing quote, e.g. "== 'a' garbage 'b'", is a syntax error rather than a string.
225
+ if (match = operand.match(/\A'((?:[^'\\]|\\.)*)'\z/m))
226
+ # Ruby single quoted strings only escape the backslash and the single quote
227
+ return match[1].gsub(/\\([\\'])/) { $1 }
228
+ end
229
+ if (match = operand.match(/\A"((?:[^"\\]|\\.)*)"\z/m))
230
+ return unescape_double_quoted(match[1])
184
231
  end
232
+ return nil if operand == "nil"
233
+ return false if operand == "false"
234
+ return true if operand == "true"
185
235
 
186
- # Handle other operand types
187
- if operand == "nil"
188
- operand = nil
189
- elsif operand == "false"
190
- operand = false
191
- elsif operand == "true"
192
- operand = true
236
+ # Ruby's Float() rejects these but Python's float() accepts them
237
+ case operand.upcase
238
+ when 'INFINITY', '+INFINITY', 'INF', '+INF'
239
+ return Float::INFINITY
240
+ when '-INFINITY', '-INF'
241
+ return -Float::INFINITY
242
+ when 'NAN', '+NAN', '-NAN'
243
+ return Float::NAN
193
244
  else
194
- begin
195
- operand = JSON.parse(operand)
196
- rescue JSON::ParserError
197
- raise "ERROR: Unable to parse operand: #{operand}"
245
+ # Not an infinity or NaN keyword so fall through to the formats below
246
+ end
247
+
248
+ # Arrays are parsed recursively so their elements follow exactly the same rules as a
249
+ # bare operand. JSON alone would reject ['ON', 'OFF'], [0xA, 0xB] and [true, nil].
250
+ if operand.start_with?('[') and operand.end_with?(']')
251
+ return split_operand_list(operand[1..-2]).map { |element| extract_operand(element) }
252
+ end
253
+ # JSON handles decimal numbers and hashes
254
+ begin
255
+ return JSON.parse(operand)
256
+ rescue JSON::ParserError
257
+ # Fall through to the number formats JSON does not accept
258
+ end
259
+ # Number formats JSON does not accept
260
+ begin
261
+ # Explicit base prefix: hex (0x), octal (0o) or binary (0b)
262
+ return Integer(operand) if operand.match?(/\A[+-]?0[xXoObB][0-9a-fA-F_]+\z/)
263
+ # Integers with underscore separators, e.g. 1_000. A leading zero is deliberately
264
+ # rejected because it is ambiguous: Ruby reads 010 as octal 8 while Python rejects it.
265
+ # Write 0o10 for octal 8 or 10 for decimal 10.
266
+ return Integer(operand, 10) if operand.match?(/\A[+-]?[1-9][0-9_]*\z/)
267
+ # Floats with underscore separators, e.g. 1_000.5. A leading zero is unambiguous here.
268
+ return Float(operand) if operand.match?(/\A[+-]?[0-9][0-9_]*(?:\.[0-9_]+(?:[eE][+-]?[0-9_]+)?|[eE][+-]?[0-9_]+)\z/)
269
+ rescue ArgumentError
270
+ # Fall through to the error cases
271
+ end
272
+
273
+ # A bare word is almost always a string the user forgot to quote
274
+ if operand.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
275
+ raise NameError, "Uninitialized constant #{operand}. Did you mean '#{operand}' as a string?"
276
+ end
277
+ raise "ERROR: Unable to parse operand: #{operand}"
278
+ end
279
+
280
+ # Processes the escape sequences in the contents of a double quoted operand
281
+ def unescape_double_quoted(string)
282
+ # A \xHH or octal escape can produce a byte which is not valid UTF-8 so build the result
283
+ # in binary. Otherwise preserve the encoding of the comparison the user passed in.
284
+ # The escape must not itself be escaped, e.g. "C:\\0" is a backslash followed by a zero.
285
+ binary = string.match?(/(?<!\\)(?:\\\\)*\\(?:x\h|[0-7])/)
286
+ result = binary ? ''.b : String.new(encoding: string.encoding)
287
+ string.scan(DOUBLE_QUOTE_TOKEN_REGEX).each do |token|
288
+ replacement =
289
+ if token.match?(INTERPOLATION_REGEX)
290
+ raise "ERROR: String interpolation is not supported in an operand: #{token}. " +
291
+ 'Interpolate in the script itself or escape it as \#' + token[1] +
292
+ ' to compare against the literal text'
293
+ elsif !token.start_with?('\\')
294
+ token
295
+ elsif token.start_with?('\u{')
296
+ # \u{1F600} or \u{48 49} which is one or more whitespace separated codepoints
297
+ token[3..-2].split.map { |codepoint| pack_unicode_codepoint(codepoint, token) }.join
298
+ elsif token.length == 6 and token.start_with?('\u')
299
+ pack_unicode_codepoint(token[2..-1], token)
300
+ elsif token.length > 2 and token.start_with?('\x')
301
+ [token[2..-1].hex].pack('C')
302
+ elsif token.match?(/\A\\[0-7]/)
303
+ [token[1..-1].to_i(8) & 0xFF].pack('C')
304
+ elsif token.match?(UNSUPPORTED_ESCAPE_REGEX)
305
+ raise "ERROR: Unsupported escape sequence in operand: #{token}"
306
+ elsif token == '\u' or token == '\x'
307
+ raise "ERROR: Invalid escape sequence in operand: #{token}"
308
+ else
309
+ # Ruby drops the backslash of an escape which has no special meaning, e.g. "\q"
310
+ DOUBLE_QUOTE_ESCAPES.fetch(token[1], token[1])
311
+ end
312
+ result << (binary ? replacement.b : replacement)
313
+ end
314
+ return result
315
+ end
316
+
317
+ # Packs a single codepoint of a \u escape into a UTF-8 character. Ruby rejects a codepoint
318
+ # which is too long, above the largest Unicode character or in the UTF-16 surrogate range,
319
+ # so those raise here rather than silently packing into an invalid UTF-8 string.
320
+ def pack_unicode_codepoint(codepoint, token)
321
+ value = codepoint.hex
322
+ if codepoint.length > MAX_UNICODE_DIGITS or value > MAX_UNICODE_CODEPOINT or
323
+ UNICODE_SURROGATE_RANGE.include?(value)
324
+ raise "ERROR: Invalid Unicode codepoint in operand: #{token}"
325
+ end
326
+ return [value].pack('U')
327
+ end
328
+
329
+ # Splits the contents of a bracketed operand list on the top level commas.
330
+ # Quotes and nesting are tracked so "['a,b', [1, 2]]" is two elements, not four.
331
+ def split_operand_list(text)
332
+ elements = []
333
+ current = String.new(encoding: text.encoding)
334
+ depth = 0
335
+ quote = nil
336
+ escaped = false
337
+ text.each_char do |char|
338
+ if escaped
339
+ escaped = false
340
+ elsif quote
341
+ if char == '\\'
342
+ escaped = true
343
+ elsif char == quote
344
+ quote = nil
345
+ end
346
+ elsif char == "'" or char == '"'
347
+ quote = char
348
+ elsif char == '[' or char == '{'
349
+ depth += 1
350
+ elsif char == ']' or char == '}'
351
+ depth -= 1
352
+ raise "ERROR: Unable to parse operand: [#{text}]" if depth < 0
353
+ elsif char == ',' and depth == 0
354
+ elements << current.strip
355
+ current = String.new(encoding: text.encoding)
356
+ next
198
357
  end
358
+ current << char
199
359
  end
200
- return [operator, operand]
360
+ raise "ERROR: Unable to parse operand: [#{text}]" if depth != 0 or quote
361
+
362
+ elements << current.strip
363
+ # A single empty element is an empty list, e.g. []
364
+ return [] if elements.length == 1 and elements[0].empty?
365
+ # A trailing comma is allowed, e.g. [1,], matching both Ruby and Python list literals
366
+ elements.pop if elements[-1].empty?
367
+ raise "ERROR: Unable to parse operand: [#{text}]" if elements.empty? or elements.any?(&:empty?)
368
+
369
+ return elements
370
+ end
371
+
372
+ # Compares a telemetry value against an operand using the given operator.
373
+ # Returns false rather than raising if the two values can not be compared.
374
+ def compare_values(value, operator, operand)
375
+ case operator
376
+ when "=="
377
+ value == operand
378
+ when "!="
379
+ value != operand
380
+ when ">"
381
+ value > operand
382
+ when ">="
383
+ value >= operand
384
+ when "<"
385
+ value < operand
386
+ when "<="
387
+ value <= operand
388
+ when "in"
389
+ # 'in' is containment against a list of values, matching Python
390
+ operand.is_a?(Array) && operand.include?(value)
391
+ else
392
+ raise "ERROR: Invalid operator: '#{operator}'"
393
+ end
394
+ rescue ArgumentError, NoMethodError, TypeError
395
+ # Comparing incompatible types, e.g. nil > 1, is simply not a match
396
+ false
201
397
  end
202
398
  end
203
399
  end
@@ -19,6 +19,7 @@ module OpenC3
19
19
  response_body = nil
20
20
  begin
21
21
  endpoint = "/openc3-api/plugins?scope=#{scope}"
22
+ endpoint += "&exclude_default=true" unless default
22
23
  uri = URI.parse($api_server.generate_url + endpoint)
23
24
  auth = $api_server.generate_auth
24
25
 
@@ -30,19 +31,11 @@ module OpenC3
30
31
  http.request(request) do |response|
31
32
  response_body = response.body
32
33
  response.value() # Raises an HTTP error if the response is not 2xx (success)
33
- plugins = JSON.parse(response.body, allow_nan: true, create_additions: true)
34
- if default
35
- return plugins
36
- else
37
- return plugins.select do |plugin|
38
- !plugin.include?('openc3-cosmos-tool-') and !plugin.include?('openc3-tool-base') and
39
- !plugin.include?('openc3-cosmos-enterprise-tool-') and !plugin.include?('openc3-enterprise-tool-base')
40
- end
41
- end
34
+ return JSON.parse(response.body, allow_nan: true, create_additions: true)
42
35
  end
43
36
  end
44
37
  rescue => e
45
- raise "get_plugin_list failed due to #{e.formatted}\nURI:#{uri}\nResponse:\n#{response_body}"
38
+ raise "plugin_list failed due to #{e.formatted}\nURI:#{uri}\nResponse:\n#{response_body}"
46
39
  end
47
40
  end
48
41
 
@@ -65,7 +58,7 @@ module OpenC3
65
58
  end
66
59
  end
67
60
  rescue => e
68
- raise "get_plugin failed due to #{e.formatted}\nURI:#{uri}\nResponse:\n#{response_body}"
61
+ raise "plugin_get failed due to #{e.formatted}\nURI:#{uri}\nResponse:\n#{response_body}"
69
62
  end
70
63
  end
71
64
 
@@ -17,6 +17,8 @@
17
17
 
18
18
  require 'tempfile'
19
19
  require 'net/http'
20
+ require 'base64'
21
+ require 'openc3/utilities/local_mode'
20
22
 
21
23
  ENV['OPENC3_CLOUD'] ||= 'local'
22
24
 
@@ -53,6 +55,15 @@ module OpenC3
53
55
 
54
56
  upload_path = "#{scope}/targets_modified/#{path}"
55
57
 
58
+ if OpenC3::LocalMode.local_only_name?(path)
59
+ # The local mode volume is only mounted in the cluster so from outside
60
+ # write it through the API which never sends it to the bucket
61
+ return _upload_api_file(upload_path, io_or_string, scope: scope) unless $openc3_in_cluster
62
+ puts "Writing local #{upload_path}"
63
+ OpenC3::LocalMode.put_target_file(upload_path, io_or_string, scope: scope)
64
+ return nil
65
+ end
66
+
56
67
  if ENV['OPENC3_LOCAL_MODE'] and $openc3_in_cluster
57
68
  OpenC3::LocalMode.put_target_file(upload_path, io_or_string, scope: scope)
58
69
  io_or_string.rewind if io_or_string.respond_to?(:rewind)
@@ -91,22 +102,22 @@ module OpenC3
91
102
  # @param original [Boolean] Whether to get the original or modified file
92
103
  # @return [File|nil]
93
104
  def get_target_file(path, original: false, scope: $openc3_scope)
105
+ # Local only targets only exist in the local mode volume so original doesn't apply
106
+ if OpenC3::LocalMode.local_only_name?(path)
107
+ # The local mode volume is only mounted in the cluster so from outside
108
+ # read it through the API which serves it from the volume
109
+ return _get_api_file("targets_modified/#{path}", scope: scope) unless $openc3_in_cluster
110
+ return _get_local_file(path, scope: scope)
111
+ end
112
+
94
113
  part = "targets"
95
114
  part += "_modified" unless original
96
115
  # Loop to allow redo when switching from modified to original
97
116
  loop do
98
117
  begin
99
118
  if part == "targets_modified" and ENV['OPENC3_LOCAL_MODE']
100
- local_file = OpenC3::LocalMode.open_local_file(path, scope: scope)
101
- if local_file
102
- puts "Reading local #{scope}/#{part}/#{path}"
103
- file = Tempfile.new('target', binmode: true)
104
- file.filename = path
105
- file.write(local_file.read)
106
- local_file.close
107
- file.rewind
108
- return file
109
- end
119
+ file = _get_local_file(path, scope: scope)
120
+ return file if file
110
121
  end
111
122
 
112
123
  return _get_storage_file("#{part}/#{path}", scope: scope)
@@ -125,7 +136,53 @@ module OpenC3
125
136
 
126
137
  # These are helper methods ... should not be used directly
127
138
 
139
+ # Write a file through the API instead of a presigned URL
140
+ def _upload_api_file(path, io_or_string, bucket: 'OPENC3_CONFIG_BUCKET', scope: $openc3_scope)
141
+ data = String === io_or_string ? io_or_string : io_or_string.read
142
+ endpoint = "/openc3-api/storage/upload_file/#{path}"
143
+ puts "Writing #{path}"
144
+ response = $api_server.request('put', endpoint, query: { bucket: bucket },
145
+ data: { contents: Base64.strict_encode64(data) }, json: true, scope: scope)
146
+ if response.nil? || response.status != 200
147
+ raise "Failed to write #{path}"
148
+ end
149
+ nil
150
+ end
151
+
152
+ # Read a file through the API instead of a presigned URL
153
+ # Returns nil if the file doesn't exist
154
+ def _get_api_file(path, bucket: 'OPENC3_CONFIG_BUCKET', scope: $openc3_scope)
155
+ endpoint = "/openc3-api/storage/download_file/#{scope}/#{path}"
156
+ response = $api_server.request('get', endpoint, query: { bucket: bucket }, scope: scope)
157
+ return nil if response && response.status == 404
158
+ if response.nil? || response.status != 200
159
+ raise "Failed to read #{scope}/#{path}"
160
+ end
161
+ puts "Reading #{scope}/#{path}"
162
+ json = JSON.parse(response.body, allow_nan: true, create_additions: true)
163
+ file = Tempfile.new('target', binmode: true)
164
+ file.filename = path
165
+ file.write(Base64.decode64(json['contents']))
166
+ file.rewind
167
+ return file
168
+ end
169
+
170
+ def _get_local_file(path, scope: $openc3_scope)
171
+ local_file = OpenC3::LocalMode.open_local_file(path, scope: scope)
172
+ return nil unless local_file
173
+ puts "Reading local #{scope}/targets_modified/#{path}"
174
+ file = Tempfile.new('target', binmode: true)
175
+ file.filename = path
176
+ file.write(local_file.read)
177
+ local_file.close
178
+ file.rewind
179
+ return file
180
+ end
181
+
128
182
  def _get_download_url(path, scope: $openc3_scope)
183
+ if OpenC3::LocalMode.local_only_name?(path)
184
+ raise "#{path} belongs to a local only target (OPENC3_LOCAL_ONLY_TARGETS) and has no bucket download URL"
185
+ end
129
186
  targets = "targets_modified" # First try targets_modified
130
187
  response = $api_server.request('get', "/openc3-api/storage/exists/#{scope}/#{targets}/#{path}", query: { bucket: 'OPENC3_CONFIG_BUCKET' }, scope: scope)
131
188
  if response.status != 200
@@ -167,6 +224,20 @@ module OpenC3
167
224
  return file
168
225
  end
169
226
 
227
+ # Retrieve the file selected in open_bucket_dialog
228
+ # input is the hash returned by the frontend: { 'bucket', 'path', 'filename' }
229
+ def _get_bucket_dialog_file(input, scope: $openc3_scope)
230
+ bucket = input['bucket']
231
+ # Path from bucket dialog already includes scope prefix (e.g. DEFAULT/targets/...)
232
+ # _get_storage_file prepends scope, so strip it from the path to avoid doubling
233
+ path = input['path'].sub(/\A#{Regexp.escape(scope)}\//, '')
234
+ file = _get_storage_file(path, bucket: bucket, scope: scope)
235
+ file.filename = input['filename']
236
+ file.bucket = bucket
237
+ file.bucket_path = input['path']
238
+ file
239
+ end
240
+
170
241
  def _get_uri(url)
171
242
  if $openc3_in_cluster
172
243
  case ENV.fetch('OPENC3_CLOUD', nil)