openc3 7.4.0 → 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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +4 -3
  3. data/bin/pipinstall +18 -5
  4. data/bin/pipuninstall +1 -1
  5. data/bin/uvinstall +36 -8
  6. data/data/config/default_plugins.yaml +43 -0
  7. data/data/config/interface_modifiers.yaml +22 -7
  8. data/data/config/item_modifiers.yaml +14 -4
  9. data/data/config/plugins.yaml +1 -1
  10. data/data/config/widgets.yaml +1 -1
  11. data/lib/openc3/api/cmd_api.rb +10 -13
  12. data/lib/openc3/api/interface_api.rb +1 -2
  13. data/lib/openc3/api/limits_api.rb +1 -1
  14. data/lib/openc3/api/metrics_api.rb +26 -1
  15. data/lib/openc3/api/router_api.rb +1 -2
  16. data/lib/openc3/api/tlm_api.rb +21 -21
  17. data/lib/openc3/config/config_parser.rb +3 -1
  18. data/lib/openc3/core_ext/tempfile.rb +2 -0
  19. data/lib/openc3/interfaces/file_interface.rb +6 -1
  20. data/lib/openc3/interfaces/http_client_interface.rb +14 -6
  21. data/lib/openc3/interfaces/interface.rb +27 -3
  22. data/lib/openc3/interfaces/protocols/protocol.rb +8 -0
  23. data/lib/openc3/interfaces/read_queue_interface.rb +331 -0
  24. data/lib/openc3/interfaces/stream_interface.rb +22 -3
  25. data/lib/openc3/interfaces/tcpip_server_interface.rb +13 -0
  26. data/lib/openc3/interfaces/udp_interface.rb +16 -2
  27. data/lib/openc3/interfaces.rb +1 -0
  28. data/lib/openc3/io/json_api_object.rb +24 -3
  29. data/lib/openc3/io/json_drb_object.rb +3 -2
  30. data/lib/openc3/logs/log_writer.rb +13 -4
  31. data/lib/openc3/microservices/decom_microservice.rb +7 -4
  32. data/lib/openc3/microservices/interface_microservice.rb +146 -50
  33. data/lib/openc3/microservices/log_microservice.rb +1 -3
  34. data/lib/openc3/microservices/microservice.rb +25 -1
  35. data/lib/openc3/microservices/plugin_microservice.rb +32 -0
  36. data/lib/openc3/microservices/text_log_microservice.rb +1 -3
  37. data/lib/openc3/models/interface_model.rb +1 -1
  38. data/lib/openc3/models/interface_status_model.rb +6 -0
  39. data/lib/openc3/models/metric_model.rb +52 -4
  40. data/lib/openc3/models/microservice_model.rb +7 -3
  41. data/lib/openc3/models/plugin_model.rb +12 -12
  42. data/lib/openc3/models/python_package_model.rb +3 -13
  43. data/lib/openc3/models/reaction_model.rb +6 -3
  44. data/lib/openc3/models/setting_model.rb +5 -3
  45. data/lib/openc3/models/target_model.rb +83 -9
  46. data/lib/openc3/operators/microservice_operator.rb +5 -0
  47. data/lib/openc3/operators/operator.rb +69 -0
  48. data/lib/openc3/packets/packet.rb +10 -4
  49. data/lib/openc3/packets/packet_config.rb +48 -2
  50. data/lib/openc3/packets/packet_item.rb +27 -14
  51. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  52. data/lib/openc3/packets/parsers/xtce_parser.rb +8 -1
  53. data/lib/openc3/packets/structure_item.rb +3 -2
  54. data/lib/openc3/script/api_shared.rb +58 -84
  55. data/lib/openc3/script/extract.rb +215 -19
  56. data/lib/openc3/script/plugins.rb +4 -11
  57. data/lib/openc3/script/storage.rb +81 -10
  58. data/lib/openc3/script/tables.rb +5 -1
  59. data/lib/openc3/system/system.rb +9 -6
  60. data/lib/openc3/topics/command_decom_topic.rb +1 -1
  61. data/lib/openc3/topics/command_topic.rb +2 -1
  62. data/lib/openc3/topics/router_topic.rb +2 -1
  63. data/lib/openc3/topics/telemetry_decom_topic.rb +1 -1
  64. data/lib/openc3/topics/telemetry_topic.rb +3 -2
  65. data/lib/openc3/topics/topic.rb +154 -2
  66. data/lib/openc3/utilities/cli_generator.rb +67 -15
  67. data/lib/openc3/utilities/config_overlay.rb +35 -17
  68. data/lib/openc3/utilities/default_plugins.rb +33 -0
  69. data/lib/openc3/utilities/local_mode.rb +82 -46
  70. data/lib/openc3/utilities/metric.rb +16 -0
  71. data/lib/openc3/utilities/process_stats.rb +160 -0
  72. data/lib/openc3/utilities/pypi_url.rb +65 -0
  73. data/lib/openc3/utilities/questdb_client.rb +43 -46
  74. data/lib/openc3/utilities/running_script.rb +22 -18
  75. data/lib/openc3/utilities/store_autoload.rb +20 -3
  76. data/lib/openc3/utilities/store_queued.rb +37 -9
  77. data/lib/openc3/utilities/target_file.rb +57 -12
  78. data/lib/openc3/utilities/topic_lag_monitor.rb +193 -0
  79. data/lib/openc3/version.rb +5 -5
  80. data/templates/conversion/conversion.py +6 -2
  81. data/templates/conversion/conversion.rb +5 -0
  82. data/templates/plugin/.github/workflows/playwright.yml +62 -0
  83. data/templates/plugin/.github/workflows/release.yml +45 -0
  84. data/templates/plugin/.github/workflows/unit-tests.yml +35 -0
  85. data/templates/plugin/.gitignore +6 -0
  86. data/templates/plugin/README.md +114 -2
  87. data/templates/plugin/lib/PACKAGE/__init__.py +4 -0
  88. data/templates/plugin/plugin.gemspec +10 -1
  89. data/templates/plugin/pyproject.toml +119 -0
  90. data/templates/tool_angular/package.json +2 -2
  91. data/templates/tool_angular/pnpm-workspace.yaml +16 -0
  92. data/templates/tool_react/package.json +1 -1
  93. data/templates/tool_react/pnpm-workspace.yaml +16 -0
  94. data/templates/tool_svelte/package.json +2 -1
  95. data/templates/tool_svelte/pnpm-workspace.yaml +20 -0
  96. data/templates/tool_vue/package.json +3 -3
  97. data/templates/tool_vue/pnpm-workspace.yaml +16 -0
  98. data/templates/widget/package.json +2 -2
  99. data/templates/widget/pnpm-workspace.yaml +16 -0
  100. metadata +36 -1
@@ -85,8 +85,22 @@ module OpenC3
85
85
  # The allowable state colors
86
86
  STATE_COLORS = [:GREEN, :YELLOW, :RED]
87
87
 
88
+ # PacketItemLimits is created on first access since most items have no
89
+ # limits and large definitions have hundreds of thousands of items.
88
90
  # @return [PacketItemLimits] All information regarding limits for this PacketItem
89
- attr_reader :limits
91
+ def limits
92
+ @limits ||= PacketItemLimits.new
93
+ end
94
+
95
+ # @return [Symbol, nil] The limits state without creating PacketItemLimits
96
+ def limits_state
97
+ @limits ? @limits.state : nil
98
+ end
99
+
100
+ # @return [Hash, nil] The limits values without creating PacketItemLimits
101
+ def limits_values
102
+ @limits ? @limits.values : nil
103
+ end
90
104
 
91
105
  # @return [Boolean] Whether the parameter must be obfuscated from logs or not
92
106
  attr_accessor :obfuscate
@@ -110,7 +124,7 @@ module OpenC3
110
124
  @obfuscate = false
111
125
  @messages_disabled = nil
112
126
  @state_colors = nil
113
- @limits = PacketItemLimits.new
127
+ @limits = nil
114
128
  @meta = nil
115
129
  end
116
130
 
@@ -165,7 +179,6 @@ module OpenC3
165
179
  end
166
180
 
167
181
  @states = upcase_states
168
- @state_colors ||= {}
169
182
  else
170
183
  @states = nil
171
184
  end
@@ -317,7 +330,7 @@ module OpenC3
317
330
  item.hazardous = self.hazardous.clone if self.hazardous
318
331
  item.messages_disabled = self.messages_disabled.clone if self.messages_disabled
319
332
  item.state_colors = self.state_colors.clone if self.state_colors
320
- item.limits = self.limits.clone if self.limits
333
+ item.limits = @limits.clone if @limits
321
334
  item.meta = self.meta.clone if @meta
322
335
  item.obfuscate = self.obfuscate.clone if @obfuscate
323
336
  item
@@ -401,16 +414,16 @@ module OpenC3
401
414
  config << self.read_conversion.to_config(:READ) if self.read_conversion
402
415
  config << self.write_conversion.to_config(:WRITE) if self.write_conversion
403
416
 
404
- if self.limits.values
405
- self.limits.values.each do |limits_set, limits_values|
406
- config << " LIMITS #{limits_set} #{self.limits.persistence_setting} #{self.limits.enabled ? 'ENABLED' : 'DISABLED'} #{limits_values[0]} #{limits_values[1]} #{limits_values[2]} #{limits_values[3]}"
417
+ if limits_values
418
+ @limits.values.each do |limits_set, limits_values|
419
+ config << " LIMITS #{limits_set} #{@limits.persistence_setting} #{@limits.enabled ? 'ENABLED' : 'DISABLED'} #{limits_values[0]} #{limits_values[1]} #{limits_values[2]} #{limits_values[3]}"
407
420
  if limits_values[4] && limits_values[5]
408
421
  config << " #{limits_values[4]} #{limits_values[5]}\n"
409
422
  else
410
423
  config << "\n"
411
424
  end
412
425
  end
413
- config << self.limits.response.to_config if self.limits.response
426
+ config << @limits.response.to_config if @limits.response
414
427
  end
415
428
 
416
429
  if @meta
@@ -456,19 +469,19 @@ module OpenC3
456
469
  config['write_conversion'] = self.write_conversion.as_json(*a) if self.write_conversion
457
470
 
458
471
  config['limits'] ||= {}
459
- if self.limits.enabled
472
+ if @limits && @limits.enabled
460
473
  config['limits']['enabled'] = true
461
- elsif self.limits.values || (self.state_colors && self.state_colors.length > 0)
474
+ elsif limits_values || (self.state_colors && self.state_colors.length > 0)
462
475
  # Only set to false if there are limits or state colors
463
476
  # to avoid items without limits acting like they can be enabled
464
477
  config['limits']['enabled'] = false
465
478
  end
466
- if self.limits.values
479
+ if limits_values
467
480
  # Only set these if there are limits.values because persistence_setting has a default
468
481
  # and we don't want keys on the 'limits' hash if there aren't any limits
469
- config['limits']['persistence_setting'] = self.limits.persistence_setting if self.limits.persistence_setting
470
- config['limits']['response'] = self.limits.response.to_s if self.limits.response
471
- self.limits.values.each do |limits_set, limits_values|
482
+ config['limits']['persistence_setting'] = @limits.persistence_setting if @limits.persistence_setting
483
+ config['limits']['response'] = @limits.response.to_s if @limits.response
484
+ @limits.values.each do |limits_set, limits_values|
472
485
  limits = {}
473
486
  limits['red_low'] = limits_values[0]
474
487
  limits['yellow_low'] = limits_values[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)
@@ -22,6 +22,10 @@ module OpenC3
22
22
  class XtceParser
23
23
  attr_accessor :current_target_name
24
24
 
25
+ # The target the file defined, still readable after parsing finishes.
26
+ # current_target_name is cleared by reset_processing_variables.
27
+ attr_reader :parsed_target_name
28
+
25
29
  # Processes a XTCE formatted OpenC3 configuration file
26
30
  #
27
31
  # @param commands [Hash<String=>Packet>] Hash of all the command packets
@@ -32,8 +36,10 @@ module OpenC3
32
36
  # that were created while parsing the configuration
33
37
  # @param filename [String] The name of the configuration file
34
38
  # @param target_name [String] Override the target name found in the XTCE file
39
+ # @return [String] The target name the packets were added under. The caller
40
+ # needs this to build the ID lookup metadata for those packets.
35
41
  def self.process(commands, telemetry, warnings, filename, target_name = nil)
36
- XtceParser.new(commands, telemetry, warnings, filename, target_name)
42
+ XtceParser.new(commands, telemetry, warnings, filename, target_name).parsed_target_name
37
43
  end
38
44
 
39
45
  def self.reverse_packet_order(target_name, cmd_or_tlm_hash)
@@ -91,6 +97,7 @@ module OpenC3
91
97
  XtceParser.reverse_packet_order(@current_target_name, @commands)
92
98
  XtceParser.reverse_packet_order(@current_target_name, @telemetry)
93
99
 
100
+ @parsed_target_name = @current_target_name
94
101
  reset_processing_variables()
95
102
  end
96
103
 
@@ -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)
@@ -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