openc3 7.4.1 → 7.5.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.
Files changed (99) 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 +149 -160
  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 +225 -71
  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 +14 -4
  27. data/lib/openc3/microservices/decom_microservice.rb +30 -5
  28. data/lib/openc3/microservices/interface_decom_common.rb +3 -1
  29. data/lib/openc3/microservices/interface_microservice.rb +146 -50
  30. data/lib/openc3/microservices/log_microservice.rb +1 -3
  31. data/lib/openc3/microservices/microservice.rb +25 -1
  32. data/lib/openc3/microservices/plugin_microservice.rb +32 -0
  33. data/lib/openc3/microservices/text_log_microservice.rb +1 -3
  34. data/lib/openc3/models/cvt_model.rb +7 -2
  35. data/lib/openc3/models/interface_model.rb +1 -1
  36. data/lib/openc3/models/interface_status_model.rb +6 -0
  37. data/lib/openc3/models/metric_model.rb +52 -4
  38. data/lib/openc3/models/microservice_model.rb +7 -3
  39. data/lib/openc3/models/plugin_model.rb +12 -12
  40. data/lib/openc3/models/python_package_model.rb +3 -13
  41. data/lib/openc3/models/target_model.rb +92 -15
  42. data/lib/openc3/operators/microservice_operator.rb +5 -0
  43. data/lib/openc3/operators/operator.rb +69 -0
  44. data/lib/openc3/packets/packet.rb +10 -4
  45. data/lib/openc3/packets/packet_config.rb +12 -1
  46. data/lib/openc3/packets/packet_item.rb +40 -22
  47. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  48. data/lib/openc3/packets/structure_item.rb +3 -2
  49. data/lib/openc3/script/api_shared.rb +62 -86
  50. data/lib/openc3/script/extract.rb +263 -20
  51. data/lib/openc3/script/plugins.rb +4 -11
  52. data/lib/openc3/script/script.rb +1 -1
  53. data/lib/openc3/script/storage.rb +81 -10
  54. data/lib/openc3/system/system.rb +9 -6
  55. data/lib/openc3/topics/command_decom_topic.rb +1 -1
  56. data/lib/openc3/topics/command_topic.rb +2 -1
  57. data/lib/openc3/topics/limits_event_topic.rb +90 -3
  58. data/lib/openc3/topics/router_topic.rb +7 -2
  59. data/lib/openc3/topics/system_events_topic.rb +9 -6
  60. data/lib/openc3/topics/telemetry_decom_topic.rb +1 -1
  61. data/lib/openc3/topics/telemetry_topic.rb +3 -2
  62. data/lib/openc3/topics/topic.rb +162 -3
  63. data/lib/openc3/utilities/bucket.rb +5 -0
  64. data/lib/openc3/utilities/cli_generator.rb +67 -15
  65. data/lib/openc3/utilities/config_overlay.rb +35 -17
  66. data/lib/openc3/utilities/default_plugins.rb +33 -0
  67. data/lib/openc3/utilities/local_mode.rb +82 -46
  68. data/lib/openc3/utilities/metric.rb +16 -0
  69. data/lib/openc3/utilities/process_stats.rb +160 -0
  70. data/lib/openc3/utilities/pypi_url.rb +65 -0
  71. data/lib/openc3/utilities/questdb_client.rb +140 -32
  72. data/lib/openc3/utilities/running_script.rb +22 -18
  73. data/lib/openc3/utilities/secrets.rb +5 -0
  74. data/lib/openc3/utilities/store_autoload.rb +23 -4
  75. data/lib/openc3/utilities/store_queued.rb +37 -9
  76. data/lib/openc3/utilities/target_file.rb +57 -12
  77. data/lib/openc3/utilities/topic_lag_monitor.rb +193 -0
  78. data/lib/openc3/version.rb +5 -5
  79. data/templates/conversion/conversion.py +6 -2
  80. data/templates/conversion/conversion.rb +5 -0
  81. data/templates/plugin/.github/workflows/playwright.yml +62 -0
  82. data/templates/plugin/.github/workflows/release.yml +45 -0
  83. data/templates/plugin/.github/workflows/unit-tests.yml +35 -0
  84. data/templates/plugin/.gitignore +6 -0
  85. data/templates/plugin/README.md +114 -2
  86. data/templates/plugin/lib/PACKAGE/__init__.py +4 -0
  87. data/templates/plugin/plugin.gemspec +10 -1
  88. data/templates/plugin/pyproject.toml +119 -0
  89. data/templates/tool_angular/package.json +2 -2
  90. data/templates/tool_angular/pnpm-workspace.yaml +16 -0
  91. data/templates/tool_react/package.json +1 -1
  92. data/templates/tool_react/pnpm-workspace.yaml +16 -0
  93. data/templates/tool_svelte/package.json +2 -1
  94. data/templates/tool_svelte/pnpm-workspace.yaml +20 -0
  95. data/templates/tool_vue/package.json +3 -3
  96. data/templates/tool_vue/pnpm-workspace.yaml +16 -0
  97. data/templates/widget/package.json +2 -2
  98. data/templates/widget/pnpm-workspace.yaml +16 -0
  99. metadata +16 -1
@@ -26,7 +26,7 @@ module OpenC3
26
26
  # @param warnings [Array<String>] Array of string warnings which will be
27
27
  # appended with any warnings found when parsing the limits
28
28
  def self.parse(parser, packet, cmd_or_tlm, item, warnings)
29
- raise parser.error("Items with LIMITS can't define STATE") if item.limits.values
29
+ raise parser.error("Items with LIMITS can't define STATE") if item.limits_values
30
30
  raise parser.error("Items with UNITS can't define STATE") if item.units
31
31
 
32
32
  parser = StateParser.new(parser)
@@ -143,7 +143,8 @@ module OpenC3
143
143
  raise ArgumentError, "name must be a String but is a #{name.class}" unless String === name
144
144
  raise ArgumentError, "name must contain at least one character" if name.empty?
145
145
 
146
- @name = name.upcase.clone.freeze
146
+ # Dedup since many packets share the same item names
147
+ @name = -name.upcase
147
148
  verify_overall() if @structure_item_constructed
148
149
  end
149
150
 
@@ -151,7 +152,7 @@ module OpenC3
151
152
  raise ArgumentError, "key must be a String but is a #{key.class}" unless String === key
152
153
  raise ArgumentError, "key must contain at least one character" if key.empty?
153
154
 
154
- @key = key
155
+ @key = -key
155
156
  end
156
157
 
157
158
  def endianness=(endianness)
@@ -37,7 +37,7 @@ module OpenC3
37
37
  # @param args [String|Array<String>] See the description for calling style
38
38
  # @param type [Symbol] Telemetry type, :RAW, :CONVERTED (default), :FORMATTED
39
39
  def check(*args, type: :CONVERTED, scope: $openc3_scope, token: $openc3_token)
40
- _check(*args, scope: scope) { |tgt, pkt, item| tlm(tgt, pkt, item, type: type, scope: scope, token: token) }
40
+ _check(*args, type: type, scope: scope, token: token) { |tgt, pkt, item| tlm(tgt, pkt, item, type: type, scope: scope, token: token) }
41
41
  end
42
42
 
43
43
  def check_raw(*args, scope: $openc3_scope, token: $openc3_token)
@@ -525,12 +525,12 @@ module OpenC3
525
525
  # Implementation of the various check commands. It yields back to the
526
526
  # caller to allow the return of the value through various telemetry calls.
527
527
  # This method should not be called directly by application code.
528
- def _check(*args, scope: $openc3_scope, token: $openc3_token)
528
+ def _check(*args, type: :CONVERTED, scope: $openc3_scope, token: $openc3_token)
529
529
  target_name, packet_name, item_name, comparison_to_eval = _check_process_args(args, 'check')
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, type, comparison_to_eval, value, scope: scope, token: token)
534
534
  else
535
535
  puts "CHECK: #{_upcase(target_name, packet_name, item_name)} == #{value.nil? ? 'nil' : value.inspect}"
536
536
  end
@@ -596,6 +596,8 @@ module OpenC3
596
596
  quiet: false,
597
597
  scope: $openc3_scope, token: $openc3_token)
598
598
  type = (check ? 'CHECK' : 'WAIT')
599
+ # LATEST would compare the RECEIVED_COUNT of whichever packet was received most recently
600
+ raise "LATEST is not supported by wait_packet, specify a packet name" if packet_name.to_s.upcase == 'LATEST'
599
601
  initial_count = tlm(target_name, packet_name, 'RECEIVED_COUNT', scope: scope, token: token)
600
602
  # If the packet has not been received the initial_count could be nil
601
603
  initial_count = 0 unless initial_count
@@ -733,28 +735,16 @@ module OpenC3
733
735
  return [target_name, packet_name, item_name, comparison_to_eval, timeout, polling_rate]
734
736
  end
735
737
 
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)
738
+ # Waits for the comparison to be true or the timeout to expire.
739
+ # The comparison is a callable which takes the telemetry value and returns true or false.
740
+ # A block passed by the user takes precedence over the comparison.
741
+ def _openc3_script_wait_implementation(target_name, packet_name, item_name, value_type, timeout, polling_rate, comparison, scope: $openc3_scope, token: $openc3_token, &block)
742
+ condition = block || comparison
737
743
  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
744
  while true
742
745
  work_start = Time.now.sys
743
746
  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
747
+ return true, value if condition and condition.call(value)
758
748
  break if Time.now.sys >= end_time
759
749
 
760
750
  delta = Time.now.sys - work_start
@@ -766,58 +756,62 @@ module OpenC3
766
756
 
767
757
  if canceled
768
758
  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
759
+ if condition and condition.call(value)
760
+ return true, value
775
761
  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
762
+ return false, value
786
763
  end
787
764
  end
788
765
  end
789
766
 
790
767
  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
768
+ end
769
+
770
+ # Builds a callable which compares a telemetry value against the given comparison string,
771
+ # e.g. "> 1". Returns nil if there is no comparison, e.g. a block based wait_check().
772
+ # Raises if the comparison is invalid, e.g. an unsupported operator or unparsable operand.
773
+ def _comparison_implementation(target_name, packet_name, item_name, value_type, comparison_to_eval, scope: $openc3_scope, token: $openc3_token)
774
+ return nil unless comparison_to_eval
775
+
776
+ operator, operand = extract_comparison(comparison_to_eval, value_type) { get_item(target_name, packet_name, item_name, scope: scope, token: token) }
777
+ return nil unless operator
778
+ lambda { |value| compare_values(value, operator, operand) }
799
779
  end
800
780
 
801
781
  # Wait for a converted telemetry item to pass a comparison
802
782
  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
783
+ # The comparison text is logged whether or not it is parsed so it is always validated
784
+ if comparison_to_eval and !comparison_to_eval.is_printable?
785
+ raise "ERROR: Invalid comparison to non-ascii value"
807
786
  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)
787
+ # A user supplied block is the condition so the comparison text is never parsed
788
+ comparison = block ? nil : _comparison_implementation(target_name, packet_name, item_name, value_type, comparison_to_eval, scope: scope, token: token)
789
+ _openc3_script_wait_implementation(target_name, packet_name, item_name, value_type, timeout, polling_rate, comparison, scope: scope, token: token, &block)
809
790
  end
810
791
 
811
792
  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)
793
+ comparison = lambda do |value|
794
+ begin
795
+ value >= (expected_value - tolerance) and value <= (expected_value + tolerance)
796
+ rescue ArgumentError, NoMethodError, TypeError
797
+ false
798
+ end
799
+ end
800
+ _openc3_script_wait_implementation(target_name, packet_name, item_name, value_type, timeout, polling_rate, comparison, scope: scope, token: token, &block)
814
801
  end
815
802
 
816
803
  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)
804
+ comparison = lambda do |values|
805
+ begin
806
+ next false unless values.is_a?(Array) and values.length == array_size
807
+ array_size.times.all? do |i|
808
+ values[i] >= (expected_value[i] - tolerance[i]) and values[i] <= (expected_value[i] + tolerance[i])
809
+ end
810
+ rescue ArgumentError, NoMethodError, TypeError
811
+ false
812
+ end
813
+ end
814
+ _openc3_script_wait_implementation(target_name, packet_name, item_name, value_type, timeout, polling_rate, comparison, scope: scope, token: token, &block)
821
815
  end
822
816
 
823
817
  # Wait on an expression to be true.
@@ -859,17 +853,15 @@ module OpenC3
859
853
  end
860
854
  end
861
855
 
862
- def _check_eval(target_name, packet_name, item_name, comparison_to_eval, value)
863
- string = "value " + comparison_to_eval
856
+ def _check_comparison(target_name, packet_name, item_name, value_type, comparison_to_eval, value, scope: $openc3_scope, token: $openc3_token)
864
857
  check_str = "CHECK: #{_upcase(target_name, packet_name, item_name)} #{comparison_to_eval}"
865
858
  # 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
859
  value_str = value.is_a?(String) ? "'#{value}'" : value
868
860
  value_str = 'nil' if value.nil? # Show user nil value as 'nil'
869
861
  with_value = "with value == #{value_str}"
870
862
 
871
- eval_is_valid = _check_eval_validity(value, comparison_to_eval)
872
- unless eval_is_valid
863
+ operator, operand = extract_comparison(comparison_to_eval, value_type) { get_item(target_name, packet_name, item_name, scope: scope, token: token) }
864
+ unless _valid_comparison?(value, operator, operand)
873
865
  message = "Invalid comparison for types"
874
866
  if $disconnect
875
867
  puts "ERROR: #{message}"
@@ -878,7 +870,7 @@ module OpenC3
878
870
  end
879
871
  end
880
872
 
881
- if eval_is_valid && eval(string)
873
+ if compare_values(value, operator, operand)
882
874
  puts "#{check_str} success #{with_value}"
883
875
  else
884
876
  message = "#{check_str} failed #{with_value}"
@@ -888,35 +880,19 @@ module OpenC3
888
880
  raise CheckError, message
889
881
  end
890
882
  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
883
  end
900
884
 
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
885
+ # Returns whether the value and operand can be meaningfully compared with the operator.
886
+ # Note this is only used by check() because wait() polls until the value changes.
887
+ def _valid_comparison?(value, operator, operand)
888
+ return true if operator.nil?
916
889
 
917
890
  if [">=", "<=", ">", "<"].include?(operator)
918
- return false if value.nil? || operand.nil? || value.is_a?(Array) || operand.is_a?(Array)
891
+ return false if value.nil? or operand.nil?
892
+ return false if value.is_a?(Array) or operand.is_a?(Array)
893
+ return false if value.is_a?(String) != operand.is_a?(String)
919
894
  end
895
+ # Note 'in' does not need a check here because the parser already requires a list operand
920
896
 
921
897
  return true
922
898
  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.
@@ -164,9 +195,8 @@ module OpenC3
164
195
  end
165
196
 
166
197
  # Splits `check()` comparison expressions, e.g. "== 'foo bar'" becomes ["==", "foo bar"]
167
- def extract_operator_and_operand_from_comparison(comparison)
168
- valid_operators = ["==", "!=", ">=", "<=", ">", "<", "in"]
169
-
198
+ # The optional block resolves an unquoted word, see extract_operand
199
+ def extract_operator_and_operand_from_comparison(comparison, &resolve_name)
170
200
  operator, operand = comparison.split(nil, 2) # Ruby: second split arg is max number of resultant elements
171
201
 
172
202
  if operand.nil?
@@ -175,29 +205,242 @@ module OpenC3
175
205
  return [nil, nil]
176
206
  end
177
207
 
178
- raise "ERROR: Invalid operator: '#{operator}'" unless valid_operators.include?(operator)
208
+ raise "ERROR: Invalid operator: '#{operator}'" unless COMPARISON_OPERATORS.include?(operator)
179
209
 
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]
210
+ # split leaves any trailing whitespace on the operand which the anchored matches reject
211
+ operand = extract_operand(operand.strip, &resolve_name)
212
+ # 'in' is containment against a list of values in both Ruby and Python.
213
+ # Enforced here so check(), wait() and wait_check() all reject the same thing.
214
+ if operator == "in" and !operand.is_a?(Array)
215
+ raise "ERROR: The 'in' operator requires a list operand: #{operand.inspect}"
216
+ end
217
+
218
+ return [operator, operand]
219
+ end
220
+
221
+ # Converts the operand of a `check()` comparison expression into a Ruby value.
222
+ # Note this deliberately does not eval the operand so only literal values are supported.
223
+ # The optional block is called with an unquoted word, including one inside a list, and
224
+ # returns the value to use or nil if the word is not recognized, e.g. a state name.
225
+ def extract_operand(operand, &resolve_name)
226
+ # A quoted operand must be a single complete string literal. Anything trailing the
227
+ # closing quote, e.g. "== 'a' garbage 'b'", is a syntax error rather than a string.
228
+ if (match = operand.match(/\A'((?:[^'\\]|\\.)*)'\z/m))
229
+ # Ruby single quoted strings only escape the backslash and the single quote
230
+ return match[1].gsub(/\\([\\'])/) { $1 }
231
+ end
232
+ if (match = operand.match(/\A"((?:[^"\\]|\\.)*)"\z/m))
233
+ return unescape_double_quoted(match[1])
184
234
  end
235
+ return nil if operand == "nil"
236
+ return false if operand == "false"
237
+ return true if operand == "true"
185
238
 
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
239
+ # Ruby's Float() rejects these but Python's float() accepts them
240
+ case operand.upcase
241
+ when 'INFINITY', '+INFINITY', 'INF', '+INF'
242
+ return Float::INFINITY
243
+ when '-INFINITY', '-INF'
244
+ return -Float::INFINITY
245
+ when 'NAN', '+NAN', '-NAN'
246
+ return Float::NAN
193
247
  else
194
- begin
195
- operand = JSON.parse(operand)
196
- rescue JSON::ParserError
197
- raise "ERROR: Unable to parse operand: #{operand}"
248
+ # Not an infinity or NaN keyword so fall through to the formats below
249
+ end
250
+
251
+ # Arrays are parsed recursively so their elements follow exactly the same rules as a
252
+ # bare operand. JSON alone would reject ['ON', 'OFF'], [0xA, 0xB] and [true, nil].
253
+ if operand.start_with?('[') and operand.end_with?(']')
254
+ return split_operand_list(operand[1..-2]).map { |element| extract_operand(element, &resolve_name) }
255
+ end
256
+ # JSON handles decimal numbers and hashes
257
+ begin
258
+ return JSON.parse(operand)
259
+ rescue JSON::ParserError
260
+ # Fall through to the number formats JSON does not accept
261
+ end
262
+ # Number formats JSON does not accept
263
+ begin
264
+ # Explicit base prefix: hex (0x), octal (0o) or binary (0b)
265
+ return Integer(operand) if operand.match?(/\A[+-]?0[xXoObB][0-9a-fA-F_]+\z/)
266
+ # Integers with underscore separators, e.g. 1_000. A leading zero is deliberately
267
+ # rejected because it is ambiguous: Ruby reads 010 as octal 8 while Python rejects it.
268
+ # Write 0o10 for octal 8 or 10 for decimal 10.
269
+ return Integer(operand, 10) if operand.match?(/\A[+-]?[1-9][0-9_]*\z/)
270
+ # Floats with underscore separators, e.g. 1_000.5. A leading zero is unambiguous here.
271
+ return Float(operand) if operand.match?(/\A[+-]?[0-9][0-9_]*(?:\.[0-9_]+(?:[eE][+-]?[0-9_]+)?|[eE][+-]?[0-9_]+)\z/)
272
+ rescue ArgumentError
273
+ # Fall through to the error cases
274
+ end
275
+
276
+ # A bare word is almost always a string the user forgot to quote
277
+ if operand.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
278
+ value = resolve_name&.call(operand)
279
+ return value unless value.nil?
280
+
281
+ raise NameError, "Unknown state name #{operand}. Did you mean '#{operand}' as a string?"
282
+ end
283
+ raise "ERROR: Unable to parse operand: #{operand}"
284
+ end
285
+
286
+ # Processes the escape sequences in the contents of a double quoted operand
287
+ def unescape_double_quoted(string)
288
+ # A \xHH or octal escape can produce a byte which is not valid UTF-8 so build the result
289
+ # in binary. Otherwise preserve the encoding of the comparison the user passed in.
290
+ # The escape must not itself be escaped, e.g. "C:\\0" is a backslash followed by a zero.
291
+ binary = string.match?(/(?<!\\)(?:\\\\)*\\(?:x\h|[0-7])/)
292
+ result = binary ? ''.b : String.new(encoding: string.encoding)
293
+ string.scan(DOUBLE_QUOTE_TOKEN_REGEX).each do |token|
294
+ replacement =
295
+ if token.match?(INTERPOLATION_REGEX)
296
+ raise "ERROR: String interpolation is not supported in an operand: #{token}. " +
297
+ 'Interpolate in the script itself or escape it as \#' + token[1] +
298
+ ' to compare against the literal text'
299
+ elsif !token.start_with?('\\')
300
+ token
301
+ elsif token.start_with?('\u{')
302
+ # \u{1F600} or \u{48 49} which is one or more whitespace separated codepoints
303
+ token[3..-2].split.map { |codepoint| pack_unicode_codepoint(codepoint, token) }.join
304
+ elsif token.length == 6 and token.start_with?('\u')
305
+ pack_unicode_codepoint(token[2..-1], token)
306
+ elsif token.length > 2 and token.start_with?('\x')
307
+ [token[2..-1].hex].pack('C')
308
+ elsif token.match?(/\A\\[0-7]/)
309
+ [token[1..-1].to_i(8) & 0xFF].pack('C')
310
+ elsif token.match?(UNSUPPORTED_ESCAPE_REGEX)
311
+ raise "ERROR: Unsupported escape sequence in operand: #{token}"
312
+ elsif token == '\u' or token == '\x'
313
+ raise "ERROR: Invalid escape sequence in operand: #{token}"
314
+ else
315
+ # Ruby drops the backslash of an escape which has no special meaning, e.g. "\q"
316
+ DOUBLE_QUOTE_ESCAPES.fetch(token[1], token[1])
317
+ end
318
+ result << (binary ? replacement.b : replacement)
319
+ end
320
+ return result
321
+ end
322
+
323
+ # Packs a single codepoint of a \u escape into a UTF-8 character. Ruby rejects a codepoint
324
+ # which is too long, above the largest Unicode character or in the UTF-16 surrogate range,
325
+ # so those raise here rather than silently packing into an invalid UTF-8 string.
326
+ def pack_unicode_codepoint(codepoint, token)
327
+ value = codepoint.hex
328
+ if codepoint.length > MAX_UNICODE_DIGITS or value > MAX_UNICODE_CODEPOINT or
329
+ UNICODE_SURROGATE_RANGE.include?(value)
330
+ raise "ERROR: Invalid Unicode codepoint in operand: #{token}"
331
+ end
332
+ return [value].pack('U')
333
+ end
334
+
335
+ # Splits the contents of a bracketed operand list on the top level commas.
336
+ # Quotes and nesting are tracked so "['a,b', [1, 2]]" is two elements, not four.
337
+ def split_operand_list(text)
338
+ elements = []
339
+ current = String.new(encoding: text.encoding)
340
+ depth = 0
341
+ quote = nil
342
+ escaped = false
343
+ text.each_char do |char|
344
+ if escaped
345
+ escaped = false
346
+ elsif quote
347
+ if char == '\\'
348
+ escaped = true
349
+ elsif char == quote
350
+ quote = nil
351
+ end
352
+ elsif char == "'" or char == '"'
353
+ quote = char
354
+ elsif char == '[' or char == '{'
355
+ depth += 1
356
+ elsif char == ']' or char == '}'
357
+ depth -= 1
358
+ raise "ERROR: Unable to parse operand: [#{text}]" if depth < 0
359
+ elsif char == ',' and depth == 0
360
+ elements << current.strip
361
+ current = String.new(encoding: text.encoding)
362
+ next
198
363
  end
364
+ current << char
199
365
  end
200
- return [operator, operand]
366
+ raise "ERROR: Unable to parse operand: [#{text}]" if depth != 0 or quote
367
+
368
+ elements << current.strip
369
+ # A single empty element is an empty list, e.g. []
370
+ return [] if elements.length == 1 and elements[0].empty?
371
+ # A trailing comma is allowed, e.g. [1,], matching both Ruby and Python list literals
372
+ elements.pop if elements[-1].empty?
373
+ raise "ERROR: Unable to parse operand: [#{text}]" if elements.empty? or elements.any?(&:empty?)
374
+
375
+ return elements
376
+ end
377
+
378
+ # Splits a comparison like extract_operator_and_operand_from_comparison but matches state names.
379
+ # State names are stored upper case so a string operand which matches a state name ignoring case
380
+ # is compared against that state name, e.g. "== 'Connected'" matches the CONNECTED state.
381
+ # An unquoted state name, e.g. "== Connected" or "in [On, Off]", is also accepted. The block returns
382
+ # the item hash. It is only called once, when the operand could be a state name, so numeric
383
+ # comparisons don't make an additional API call. RAW values are never state names so it is never called.
384
+ def extract_comparison(comparison, value_type, &get_item)
385
+ item_states = nil
386
+ looked_up = false
387
+ states = lambda do
388
+ unless looked_up
389
+ looked_up = true
390
+ item_states = get_item.call['states'] unless value_type.to_s.upcase == 'RAW'
391
+ end
392
+ item_states
393
+ end
394
+ operator, operand = extract_operator_and_operand_from_comparison(comparison) do |name|
395
+ name.upcase if states.call&.key?(name.upcase)
396
+ end
397
+ return [operator, operand] unless operand_contains_string?(operand)
398
+
399
+ return [operator, states.call ? upcase_state_names(operand, states.call) : operand]
400
+ end
401
+
402
+ # Returns whether the operand is or contains a string, i.e. could be a state name
403
+ def operand_contains_string?(operand)
404
+ return true if operand.is_a?(String)
405
+ return operand.any? { |element| operand_contains_string?(element) } if operand.is_a?(Array)
406
+ return false
407
+ end
408
+
409
+ # State names are stored upper case so any string in the operand which matches
410
+ # a state name ignoring case is replaced with that state name
411
+ def upcase_state_names(operand, states)
412
+ if operand.is_a?(String)
413
+ return states.key?(operand.upcase) ? operand.upcase : operand
414
+ end
415
+ return operand.map { |element| upcase_state_names(element, states) } if operand.is_a?(Array)
416
+ return operand
417
+ end
418
+
419
+ # Compares a telemetry value against an operand using the given operator.
420
+ # Returns false rather than raising if the two values can not be compared.
421
+ def compare_values(value, operator, operand)
422
+ case operator
423
+ when "=="
424
+ value == operand
425
+ when "!="
426
+ value != operand
427
+ when ">"
428
+ value > operand
429
+ when ">="
430
+ value >= operand
431
+ when "<"
432
+ value < operand
433
+ when "<="
434
+ value <= operand
435
+ when "in"
436
+ # 'in' is containment against a list of values, matching Python
437
+ operand.is_a?(Array) && operand.include?(value)
438
+ else
439
+ raise "ERROR: Invalid operator: '#{operator}'"
440
+ end
441
+ rescue ArgumentError, NoMethodError, TypeError
442
+ # Comparing incompatible types, e.g. nil > 1, is simply not a match
443
+ false
201
444
  end
202
445
  end
203
446
  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
 
@@ -313,7 +313,7 @@ module OpenC3
313
313
  return disconnect if disconnect
314
314
  # The only commands allowed through in disconnect mode are read-only
315
315
  # Thus we allow the get, list, tlm and limits_enabled and subscribe methods
316
- if method_name =~ /\w*_get$|^get_\w*|\w*_list$|^list_\w*|^tlm|^limits_enabled$|^subscribe$/
316
+ if method_name =~ /\w*_get$|^get_\w*|\w*_list$|^list_\w*|^tlm|^limits_enabled\??$|^subscribe$/
317
317
  return @json_drb.method_missing(method_name, *method_params, **kw_params)
318
318
  else
319
319
  return nil