openc3 7.5.0 → 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.
@@ -119,6 +119,7 @@ module OpenC3
119
119
  # @param type [Symbol] Telemetry type, :RAW, :CONVERTED (default), :FORMATTED
120
120
  # @param received_time [Integer, nil] Optional received time as nanoseconds since Unix epoch
121
121
  def inject_tlm(target_name, packet_name, item_hash = nil, type: :CONVERTED, stored: false, received_time: nil, manual: false, scope: $openc3_scope, token: $openc3_token)
122
+ _tlm_reject_latest(packet_name.to_s.upcase, 'inject_tlm')
122
123
  authorize(permission: 'tlm_set', target_name: target_name, packet_name: packet_name, manual: manual, scope: scope, token: token)
123
124
  type = type.to_s.intern
124
125
  target_name = target_name.upcase
@@ -174,12 +175,17 @@ module OpenC3
174
175
  #
175
176
  # @param args The args must either be a string followed by a value or
176
177
  # three strings followed by a value (see the calling style in the
177
- # description).
178
+ # description). A packet name of LATEST overrides the item in every
179
+ # packet which contains it.
178
180
  # @param type [Symbol] Telemetry type, :ALL (default), :RAW, :CONVERTED, :FORMATTED
179
181
  def override_tlm(*args, type: :ALL, manual: false, scope: $openc3_scope, token: $openc3_token)
180
- target_name, packet_name, item_name, value = _set_tlm_process_args(args, __method__, scope: scope)
181
- authorize(permission: 'tlm_set', target_name: target_name, packet_name: packet_name, manual: manual, scope: scope, token: token)
182
- CvtModel.override(target_name, packet_name, item_name, value, type: type.intern, scope: scope)
182
+ target_name, packet_names, item_name, value = _set_tlm_process_args_all_packets(args, __method__, scope: scope)
183
+ packet_names.each do |packet_name|
184
+ authorize(permission: 'tlm_set', target_name: target_name, packet_name: packet_name, manual: manual, scope: scope, token: token)
185
+ end
186
+ packet_names.each do |packet_name|
187
+ CvtModel.override(target_name, packet_name, item_name, value, type: type.intern, scope: scope)
188
+ end
183
189
  end
184
190
 
185
191
  # Get the list of CVT overrides
@@ -198,13 +204,18 @@ module OpenC3
198
204
  # Favor the first syntax where possible as it is more succinct.
199
205
  #
200
206
  # @param args The args must either be a string or three strings
201
- # (see the calling style in the description).
207
+ # (see the calling style in the description). A packet name of LATEST
208
+ # normalizes the item in every packet which contains it.
202
209
  # @param type [Symbol] Telemetry type, :ALL (default), :RAW, :CONVERTED, :FORMATTED
203
210
  # Also takes :ALL which means to normalize all telemetry types
204
211
  def normalize_tlm(*args, type: :ALL, manual: false, scope: $openc3_scope, token: $openc3_token)
205
- target_name, packet_name, item_name = _tlm_process_args(args, __method__, scope: scope)
206
- authorize(permission: 'tlm_set', target_name: target_name, packet_name: packet_name, manual: manual, scope: scope, token: token)
207
- CvtModel.normalize(target_name, packet_name, item_name, type: type.intern, scope: scope)
212
+ target_name, packet_names, item_name = _tlm_process_args_all_packets(args, __method__, scope: scope)
213
+ packet_names.each do |packet_name|
214
+ authorize(permission: 'tlm_set', target_name: target_name, packet_name: packet_name, manual: manual, scope: scope, token: token)
215
+ end
216
+ packet_names.each do |packet_name|
217
+ CvtModel.normalize(target_name, packet_name, item_name, type: type.intern, scope: scope)
218
+ end
208
219
  end
209
220
 
210
221
  # Returns the raw buffer for a telemetry packet.
@@ -265,45 +276,25 @@ module OpenC3
265
276
  packet_name = orig_packet_name
266
277
  raise ArgumentError, "items must be formatted as TGT__PKT__ITEM__TYPE" if target_name.nil? || packet_name.nil? || item_name.nil? || value_type.nil?
267
278
  if orig_packet_name == 'LATEST'
268
- # TODO: Do we need to lookup ALL the possible packets for this item?
279
+ # A LATEST item can come from any packet containing it. Report the best value
280
+ # type and limits of any of them since the CVT falls back to the available
281
+ # type and historical lookups check the type available in each packet.
269
282
  # We can have a large cache_timeout of 1 because all we're trying to do is lookup a packet
270
283
  packet_name = CvtModel.determine_latest_packet_for_item(target_name, item_name, cache_timeout: 1, scope: scope)
284
+ packet_names = _tlm_packet_names(target_name, orig_packet_name, item_name, scope: scope)
285
+ else
286
+ packet_names = [packet_name]
287
+ end
288
+ # Authorize every packet whose definition is read below, not just the latest one
289
+ (packet_names | [packet_name]).each do |name|
290
+ authorize(permission: 'tlm', target_name: target_name, packet_name: name, scope: scope, token: token)
271
291
  end
272
- authorize(permission: 'tlm', target_name: target_name, packet_name: packet_name, scope: scope, token: token)
273
292
  begin
274
- item = TargetModel.packet_item(target_name, packet_name, item_name, scope: scope)
275
- if Packet::RESERVED_ITEM_NAMES.include?(item_name)
276
- value_type = 'RAW' # Must request the raw value when dealing with the reserved items
277
- end
278
-
279
- # Arrays must be accessed as RAW since there's no conversion
280
- if item['array_size']
281
- value_type = 'RAW'
282
- end
283
-
284
- case value_type
285
- when 'FORMATTED', 'WITH_UNITS'
286
- if item['format_string'] or item['units']
287
- results << [target_name, orig_packet_name, item_name, 'FORMATTED'].join('__')
288
- # This logic must match the logic in Packet#decom
289
- elsif item['states'] or (item['read_conversion'] and item['data_type'] != 'DERIVED')
290
- results << [target_name, orig_packet_name, item_name, 'CONVERTED'].join('__')
291
- else
292
- results << [target_name, orig_packet_name, item_name, 'RAW'].join('__')
293
- end
294
- when 'CONVERTED'
295
- # This logic must match the logic in Packet#decom
296
- if item['states'] or (item['read_conversion'] and item['data_type'] != 'DERIVED')
297
- results << [target_name, orig_packet_name, item_name, 'CONVERTED'].join('__')
298
- else
299
- results << [target_name, orig_packet_name, item_name, 'RAW'].join('__')
300
- end
301
- else # RAW or unknown
302
- results << [target_name, orig_packet_name, item_name, 'RAW'].join('__')
303
- end
304
-
293
+ packet_items = packet_names.map { |name| TargetModel.packet_item(target_name, name, item_name, scope: scope) }
294
+ available_type = packet_items.map { |packet_item| _tlm_available_value_type(packet_item, item_name, value_type) }.max_by { |type| VALUE_TYPE_ORDER.index(type) }
295
+ results << [target_name, orig_packet_name, item_name, available_type].join('__')
305
296
  # Tack on __LIMITS to notify that we have an available limits value
306
- if item['limits']['DEFAULT']
297
+ if packet_items.any? { |packet_item| packet_item['limits']['DEFAULT'] }
307
298
  results[-1] += '__LIMITS'
308
299
  end
309
300
  rescue RuntimeError
@@ -341,7 +332,20 @@ module OpenC3
341
332
  parts = item.to_s.upcase.split('__')
342
333
  raise ArgumentError, "items must be formatted as TGT__PKT__ITEM__TYPE" if parts.length < 4 || parts.length > 5
343
334
  target_name, packet_name, item_name, value_type, limits = parts
344
- if packet_name == 'LATEST' # Lookup packet_name in case of LATEST
335
+ if packet_name == 'LATEST'
336
+ if start_time
337
+ # The packet received most recently now isn't necessarily the one received most
338
+ # recently at start_time so keep every candidate packet for _tlm_latest_history
339
+ # Each packet must be queried for the value type and limits it has or the query fails
340
+ candidates = _tlm_packet_names(target_name, packet_name, item_name, scope: scope).map do |candidate|
341
+ packets << [target_name, candidate]
342
+ candidate_item = TargetModel.packet_item(target_name, candidate, item_name, scope: scope)
343
+ candidate_limits = (limits and candidate_item['limits']['DEFAULT']) ? limits : nil
344
+ [target_name, candidate, item_name, _tlm_available_value_type(candidate_item, item_name, value_type), candidate_limits]
345
+ end
346
+ cvt_items << [target_name, packet_name, item_name, value_type, limits, candidates]
347
+ next
348
+ end
345
349
  packet_name = CvtModel.determine_latest_packet_for_item(target_name, item_name, cache_timeout: cache_timeout, scope: scope)
346
350
  end
347
351
  # NOTE: limits is required by the historical (start_time) QuestDB lookup
@@ -352,6 +356,9 @@ module OpenC3
352
356
  packets.each do |target_name, packet_name|
353
357
  authorize(permission: 'tlm', target_name: target_name, packet_name: packet_name, manual: manual, scope: scope, token: token)
354
358
  end
359
+ if cvt_items.any? { |cvt_item| cvt_item.length == 6 }
360
+ return _tlm_latest_history(cvt_items, start_time: start_time, end_time: end_time, cache_timeout: cache_timeout, scope: scope)
361
+ end
355
362
  CvtModel.get_tlm_values(cvt_items, stale_time: stale_time, cache_timeout: cache_timeout, start_time: start_time, end_time: end_time, scope: scope)
356
363
  end
357
364
 
@@ -446,6 +453,7 @@ module OpenC3
446
453
  packets.each do |target_name, packet_name|
447
454
  target_name = target_name.upcase
448
455
  packet_name = packet_name.upcase
456
+ _tlm_reject_latest(packet_name, 'subscribe_packets')
449
457
  authorize(permission: 'tlm', target_name: target_name, packet_name: packet_name, manual: manual, scope: scope, token: token)
450
458
  topic = "#{scope}__DECOM__{#{target_name}}__#{packet_name}"
451
459
  db_shard = Store.db_shard_for_target(target_name, scope: scope)
@@ -475,7 +483,7 @@ module OpenC3
475
483
  # Group topics by db_shard for multi-shard support
476
484
  db_shard_groups = {}
477
485
  lookup.each do |topic, offset|
478
- target_name = topic.match(/__\{?([^}_]+)\}?__/)[1] rescue nil
486
+ target_name = Topic.target_name_from_topic(topic)
479
487
  db_shard = Store.db_shard_for_target(target_name, scope: scope)
480
488
  db_shard_groups[db_shard] ||= { topics: [], offsets: [] }
481
489
  db_shard_groups[db_shard][:topics] << topic
@@ -551,9 +559,15 @@ module OpenC3
551
559
  if target_name.nil? or packet_name.nil?
552
560
  raise "ERROR: Both target name and packet name required. Usage: #{method_name}(\"TGT PKT\") or #{method_name}(\"TGT\", \"PKT\")"
553
561
  end
562
+ _tlm_reject_latest(packet_name, method_name)
554
563
  return [target_name, packet_name]
555
564
  end
556
565
 
566
+ # LATEST picks a packet per item so it can't identify a whole packet
567
+ def _tlm_reject_latest(packet_name, method_name)
568
+ raise "ERROR: LATEST is not supported by #{method_name}(), specify a packet name" if packet_name == 'LATEST'
569
+ end
570
+
557
571
  def _extract_target_packet_item_names(method_name, *args)
558
572
  target_name = nil
559
573
  packet_name = nil
@@ -589,6 +603,31 @@ module OpenC3
589
603
  end
590
604
 
591
605
  def _tlm_process_args(args, method_name, cache_timeout: nil, scope: $openc3_scope, token: $openc3_token)
606
+ target_name, packet_name, item_name = _tlm_extract_args(args, method_name)
607
+ packet_name = _tlm_resolve_packet_name(target_name, packet_name, item_name, cache_timeout: cache_timeout, scope: scope)
608
+ return [target_name, packet_name, item_name]
609
+ end
610
+
611
+ def _set_tlm_process_args(args, method_name, cache_timeout: nil, scope: $openc3_scope, token: $openc3_token)
612
+ target_name, packet_name, item_name, value = _set_tlm_extract_args(args, method_name)
613
+ packet_name = _tlm_resolve_packet_name(target_name, packet_name, item_name, cache_timeout: cache_timeout, scope: scope)
614
+ return [target_name, packet_name, item_name, value]
615
+ end
616
+
617
+ # Like _tlm_process_args but returns an array of packet names where LATEST
618
+ # expands to every packet containing the item. Used by APIs which change
619
+ # an item so the change applies wherever the item appears.
620
+ def _tlm_process_args_all_packets(args, method_name, scope: $openc3_scope)
621
+ target_name, packet_name, item_name = _tlm_extract_args(args, method_name)
622
+ return [target_name, _tlm_packet_names(target_name, packet_name, item_name, scope: scope), item_name]
623
+ end
624
+
625
+ def _set_tlm_process_args_all_packets(args, method_name, scope: $openc3_scope)
626
+ target_name, packet_name, item_name, value = _set_tlm_extract_args(args, method_name)
627
+ return [target_name, _tlm_packet_names(target_name, packet_name, item_name, scope: scope), item_name, value]
628
+ end
629
+
630
+ def _tlm_extract_args(args, method_name)
592
631
  case args.length
593
632
  when 1
594
633
  target_name, packet_name, item_name = extract_fields_from_tlm_text(args[0])
@@ -600,21 +639,10 @@ module OpenC3
600
639
  # Invalid number of arguments
601
640
  raise "ERROR: Invalid number of arguments (#{args.length}) passed to #{method_name}()"
602
641
  end
603
- target_name = target_name.upcase
604
- packet_name = packet_name.upcase
605
- item_name = item_name.upcase
606
-
607
- if packet_name == 'LATEST'
608
- packet_name = CvtModel.determine_latest_packet_for_item(target_name, item_name, cache_timeout: cache_timeout, scope: scope)
609
- else
610
- # Determine if this item exists, it will raise appropriate errors if not
611
- TargetModel.packet_item(target_name, packet_name, item_name, scope: scope)
612
- end
613
-
614
- return [target_name, packet_name, item_name]
642
+ return [target_name.upcase, packet_name.upcase, item_name.upcase]
615
643
  end
616
644
 
617
- def _set_tlm_process_args(args, method_name, cache_timeout: nil, scope: $openc3_scope, token: $openc3_token)
645
+ def _set_tlm_extract_args(args, method_name)
618
646
  case args.length
619
647
  when 1
620
648
  target_name, packet_name, item_name, value = extract_fields_from_set_tlm_text(args[0])
@@ -627,18 +655,148 @@ module OpenC3
627
655
  # Invalid number of arguments
628
656
  raise "ERROR: Invalid number of arguments (#{args.length}) passed to #{method_name}()"
629
657
  end
630
- target_name = target_name.upcase
631
- packet_name = packet_name.upcase
632
- item_name = item_name.upcase
658
+ return [target_name.upcase, packet_name.upcase, item_name.upcase, value]
659
+ end
633
660
 
661
+ # Resolve LATEST to the most recently received packet containing the item
662
+ def _tlm_resolve_packet_name(target_name, packet_name, item_name, cache_timeout: nil, scope: $openc3_scope)
634
663
  if packet_name == 'LATEST'
635
664
  packet_name = CvtModel.determine_latest_packet_for_item(target_name, item_name, cache_timeout: cache_timeout, scope: scope)
636
665
  else
637
666
  # Determine if this item exists, it will raise appropriate errors if not
638
667
  TargetModel.packet_item(target_name, packet_name, item_name, scope: scope)
639
668
  end
669
+ return packet_name
670
+ end
640
671
 
641
- return [target_name, packet_name, item_name, value]
672
+ VALUE_TYPE_ORDER = %w(RAW CONVERTED FORMATTED).freeze
673
+
674
+ # Returns the value type available for an item given the requested value type
675
+ def _tlm_available_value_type(item, item_name, value_type)
676
+ # Must request the raw value when dealing with the reserved items
677
+ return 'RAW' if Packet::RESERVED_ITEM_NAMES.include?(item_name)
678
+ # Arrays must be accessed as RAW since there's no conversion
679
+ return 'RAW' if item['array_size']
680
+
681
+ # This logic must match the logic in Packet#decom
682
+ converted = (item['states'] or (item['read_conversion'] and item['data_type'] != 'DERIVED'))
683
+ case value_type
684
+ when 'FORMATTED', 'WITH_UNITS'
685
+ return 'FORMATTED' if item['format_string'] or item['units']
686
+ return converted ? 'CONVERTED' : 'RAW'
687
+ when 'CONVERTED'
688
+ return converted ? 'CONVERTED' : 'RAW'
689
+ else # RAW or unknown
690
+ return 'RAW'
691
+ end
692
+ end
693
+
694
+ # Historical lookup where some items are LATEST. Those items have a 6th element
695
+ # holding the item to look up in every packet which contains the item.
696
+ def _tlm_latest_history(cvt_items, start_time:, end_time:, cache_timeout:, scope:)
697
+ latest = cvt_items.each_index.select { |index| cvt_items[index].length == 6 }
698
+ regular = cvt_items.each_index.reject { |index| cvt_items[index].length == 6 }
699
+
700
+ # The QuestDB lookup ASOF JOINs every packet to the rows of the first, so
701
+ # a range needs a regular item first to drive the rows. Without one keep
702
+ # the realtime behavior of using the packet received most recently now.
703
+ if end_time and regular.all? { |index| cvt_items[index][2].nil? }
704
+ cvt_items = cvt_items.map do |target_name, packet_name, item_name, value_type, limits, candidates|
705
+ next [target_name, packet_name, item_name, value_type, limits] unless packet_name == 'LATEST'
706
+ packet_name = CvtModel.determine_latest_packet_for_item(target_name, item_name, cache_timeout: cache_timeout, scope: scope)
707
+ # Use the candidate so the query asks for the value type and limits that packet has
708
+ candidates.find { |candidate| candidate[1] == packet_name } || [target_name, packet_name, item_name, value_type, limits]
709
+ end
710
+ return CvtModel.tsdb_lookup(cvt_items, start_time: start_time, end_time: end_time, scope: scope)
711
+ end
712
+
713
+ # A packet without a table fails the query so skip those candidates
714
+ packets = latest.flat_map { |index| cvt_items[index][5].map { |candidate| candidate[0..1] } }.uniq
715
+ existing = CvtModel.tsdb_packets_with_tables(packets, scope: scope)
716
+ cvt_items = cvt_items.each_with_index.map do |cvt_item, index|
717
+ next cvt_item unless latest.include?(index)
718
+ cvt_item[0..4] + [cvt_item[5].select { |candidate| existing.include?(candidate[0..1]) }]
719
+ end
720
+
721
+ if end_time
722
+ query = []
723
+ query_index = {}
724
+ add = lambda { |query_item| query_index[query_item] ||= (query << query_item; query.length - 1) }
725
+ positions = Array.new(cvt_items.length)
726
+ regular.each { |index| positions[index] = add.call(cvt_items[index]) }
727
+ latest.each do |index|
728
+ positions[index] = cvt_items[index][5].map do |candidate|
729
+ target_name, packet_name = candidate
730
+ [add.call(candidate), add.call([target_name, packet_name, 'PACKET_TIMESECONDS', 'RAW', nil])]
731
+ end
732
+ end
733
+ rows = CvtModel.tsdb_lookup(query, start_time: start_time, end_time: end_time, scope: scope, flatten: false)
734
+ return rows unless rows.is_a?(Array) and rows.length > 0
735
+ rows = rows.map do |row|
736
+ positions.map do |position|
737
+ next row[position] if position.is_a?(Integer)
738
+ # Take the candidate with the newest packet time at this row
739
+ _tlm_newest(position.map { |value_index, time_index| [row[time_index][0], row[value_index]] }) || [nil, nil]
740
+ end
741
+ end
742
+ # Match tsdb_lookup which returns a flat array for a single row
743
+ return rows.length == 1 ? rows[0] : rows
744
+ end
745
+
746
+ # A point in time returns the last row of the first packet before start_time
747
+ # with the other packets ASOF JOINed at that row. A candidate packet received
748
+ # after that row would be missed so query each candidate packet on its own.
749
+ found = false
750
+ row = Array.new(cvt_items.length) { [nil, nil] }
751
+ unless regular.all? { |index| cvt_items[index][2].nil? }
752
+ result = CvtModel.tsdb_lookup(regular.map { |index| cvt_items[index] }, start_time: start_time, scope: scope, flatten: false)
753
+ if result.is_a?(Array) and result.length > 0
754
+ found = true
755
+ regular.each_with_index { |index, result_index| row[index] = result[0][result_index] }
756
+ end
757
+ end
758
+ # Group the LATEST items by candidate packet so each packet is queried once
759
+ by_packet = {}
760
+ latest.each do |index|
761
+ cvt_items[index][5].each do |candidate|
762
+ target_name, packet_name = candidate
763
+ (by_packet[[target_name, packet_name]] ||= {})[index] = candidate
764
+ end
765
+ end
766
+ found_values = Hash.new { |hash, key| hash[key] = [] }
767
+ by_packet.each do |(target_name, packet_name), packet_items|
768
+ query = packet_items.values.uniq
769
+ query << [target_name, packet_name, 'PACKET_TIMESECONDS', 'RAW', nil]
770
+ result = CvtModel.tsdb_lookup(query, start_time: start_time, scope: scope, flatten: false)
771
+ next unless result.is_a?(Array) and result.length > 0
772
+ values = result[0]
773
+ packet_items.each { |index, query_item| found_values[index] << [values[-1][0], values[query.index(query_item)]] }
774
+ end
775
+ found_values.each do |index, candidates|
776
+ value = _tlm_newest(candidates)
777
+ next unless value
778
+ found = true
779
+ row[index] = value
780
+ end
781
+ return found ? row : {}
782
+ end
783
+
784
+ # Return the value with the newest time from [time, value] pairs, or nil
785
+ def _tlm_newest(candidates)
786
+ candidates.select { |time, _| time }.max_by { |time, _| time }&.last
787
+ end
788
+
789
+ # Expand LATEST to every packet containing the item
790
+ def _tlm_packet_names(target_name, packet_name, item_name, scope: $openc3_scope)
791
+ if packet_name == 'LATEST'
792
+ packet_names = TargetModel.get_item_to_packet_map(target_name, scope: scope)[item_name]
793
+ raise "Item '#{target_name} LATEST #{item_name}' does not exist for scope: #{scope}" unless packet_names
794
+ return packet_names
795
+ else
796
+ # Determine if this item exists, it will raise appropriate errors if not
797
+ TargetModel.packet_item(target_name, packet_name, item_name, scope: scope)
798
+ return [packet_name]
799
+ end
642
800
  end
643
801
  end
644
802
  end
@@ -80,14 +80,6 @@ module OpenC3
80
80
  # Sleeper used to delay cycle thread
81
81
  @@cycle_sleeper = nil
82
82
 
83
- # Returns the Redis db_shard holding a target stream such as
84
- # SCOPE__TELEMETRY__{TARGET}__PACKET, or 0 if no target can be determined
85
- def self.db_shard_for_topic(redis_topic)
86
- target_match = redis_topic.match(/\{([^{}]+)\}/)
87
- return 0 unless target_match
88
- Store.db_shard_for_target(target_match[1], scope: redis_topic.split('__')[0])
89
- end
90
-
91
83
  # @param remote_log_directory [String] The path to store the log files
92
84
  # @param logging_enabled [Boolean] Whether to start with logging enabled
93
85
  # @param cycle_time [Integer] The amount of time in seconds before creating
@@ -222,6 +214,15 @@ module OpenC3
222
214
  end
223
215
  end
224
216
 
217
+ # The db_shard of a target topic like DEFAULT__DECOM__{INST}__HEALTH_STATUS
218
+ # or db_shard 0 for a topic without a target
219
+ def self.db_shard_for_topic(redis_topic)
220
+ scope, _, rest = redis_topic.delete_prefix('{').partition('__')
221
+ # The target is a {TARGET} part between two others, e.g. DECOM__{INST}__HEALTH_STATUS
222
+ target = rest.split('__', -1)[1...-1]&.find { |part| part.length > 2 and part.start_with?('{') and part.end_with?('}') }
223
+ (!scope.empty? and target) ? Store.db_shard_for_target(target[1...-1], scope: scope) : 0
224
+ end
225
+
225
226
  def cycle_thread_body
226
227
  @@cycle_sleeper = Sleeper.new
227
228
  while true
@@ -99,6 +99,10 @@ module OpenC3
99
99
  @limits_event_topic = "#{@scope}__openc3_limits_events"
100
100
  @topics << @limits_event_topic
101
101
  Topic.update_topic_offsets(@topics, db_shard: @db_shard)
102
+ # Last limits event read, used to detect events trimmed before they were read.
103
+ # Read from the stream rather than the thread's cached offsets. The run thread
104
+ # starts reading at or after this point, so it can only cause an extra resync.
105
+ @limits_event_offset = Topic.get_last_offset(@limits_event_topic, db_shard: @db_shard)
102
106
  # Initialize before assigning limits_change_callback - sync_system below
103
107
  # can fire the callback synchronously for any persisted-disabled items.
104
108
  @limits_response_queue = Queue.new
@@ -121,14 +125,21 @@ module OpenC3
121
125
  break if @cancel_thread
122
126
 
123
127
  begin
128
+ limits_events_checked = false
124
129
  OpenC3.in_span("read_topics") do
125
130
  Topic.read_topics(@topics, db_shard: @db_shard) do |topic, msg_id, msg_hash, redis|
126
131
  break if @cancel_thread
127
132
  if topic == @microservice_topic
128
133
  microservice_cmd(topic, msg_id, msg_hash, redis)
129
134
  elsif topic == @limits_event_topic
135
+ # Any gap is before the first event in a read so only check once per read
136
+ unless limits_events_checked
137
+ resync_if_limits_events_trimmed()
138
+ limits_events_checked = true
139
+ end
140
+ @limits_event_offset = msg_id
130
141
  event = JSON.parse(msg_hash['event'], allow_nan: true, create_additions: true)
131
- LimitsEventTopic.process_event(event)
142
+ LimitsEventTopic.process_event(event, scope: @scope)
132
143
  elsif topic =~ /__DECOMINTERFACE/
133
144
  if msg_hash.key?('inject_tlm')
134
145
  handle_inject_tlm_with_ack(msg_hash['inject_tlm'], msg_id)
@@ -161,6 +172,17 @@ module OpenC3
161
172
  @limits_response_thread = nil
162
173
  end
163
174
 
175
+ # Limits events are only read between batches of packets, so a decom microservice
176
+ # working through a backlog can fall far enough behind that a flood of LIMITS_CHANGE
177
+ # events trims control events (enable, settings, etc) before it reads them.
178
+ # Recover by reloading all limits state from the store, which is always current.
179
+ def resync_if_limits_events_trimmed
180
+ if LimitsEventTopic.events_trimmed?(@limits_event_offset, scope: @scope, db_shard: @db_shard)
181
+ @logger.warn("#{@name}: Limits events were trimmed before they could be read, resyncing limits")
182
+ LimitsEventTopic.resync_system(scope: @scope)
183
+ end
184
+ end
185
+
164
186
  def decom_packet(topic, msg_id, msg_hash, _redis)
165
187
  OpenC3.in_span("decom_packet") do
166
188
  update_topic_lag(topic, msg_id, metric_name: 'decom_topic_delta_seconds', help: 'Delta time between data written to stream and decom start')
@@ -42,6 +42,8 @@ module OpenC3
42
42
  def handle_inject_tlm_with_ack(inject_tlm_json, msg_id)
43
43
  inject_tlm_hash = JSON.parse(inject_tlm_json, allow_nan: true, create_additions: true)
44
44
  target_name = inject_tlm_hash['target_name']
45
+ # Ack on the db_shard of the target where DecomInterfaceTopic.inject_tlm waits for it
46
+ db_shard = Store.db_shard_for_target(target_name, scope: @scope)
45
47
  ack_topic = "{#{@scope}__ACKCMD}TARGET__#{target_name}"
46
48
  begin
47
49
  handle_inject_tlm(inject_tlm_json)
@@ -56,7 +58,7 @@ module OpenC3
56
58
  result: error.message
57
59
  }
58
60
  end
59
- Topic.write_topic(ack_topic, msg_hash)
61
+ Topic.write_topic(ack_topic, msg_hash, db_shard: db_shard)
60
62
  end
61
63
 
62
64
  def handle_build_cmd(build_cmd_json, msg_id)
@@ -134,8 +134,13 @@ module OpenC3
134
134
  end
135
135
  end
136
136
 
137
- def self.tsdb_lookup(items, start_time:, end_time: nil, scope: $openc3_scope)
138
- QuestDBClient.tsdb_lookup(items, start_time: start_time, end_time: end_time, scope: scope)
137
+ def self.tsdb_lookup(items, start_time:, end_time: nil, scope: $openc3_scope, flatten: true)
138
+ QuestDBClient.tsdb_lookup(items, start_time: start_time, end_time: end_time, scope: scope, flatten: flatten)
139
+ end
140
+
141
+ # Return the [target_name, packet_name] telemetry packets which have a TSDB table
142
+ def self.tsdb_packets_with_tables(packets, scope: $openc3_scope)
143
+ QuestDBClient.tlm_packets_with_tables(packets, scope: scope)
139
144
  end
140
145
 
141
146
  # Return all item values and limit state from the CVT
@@ -458,10 +458,13 @@ module OpenC3
458
458
  end
459
459
 
460
460
  def self.get_item_to_packet_map(target_name, scope:)
461
- cache_time, item_map = @@item_map_cache[target_name]
462
- return item_map if item_map and (Time.now - cache_time) < ITEM_MAP_CACHE_TIMEOUT
463
- item_map_key = "#{scope}__#{target_name}__item_to_packet_map"
461
+ # Normalize before building the key so lowercase lookups share the entry
462
+ # that update_store_item_map and undeploy refresh and invalidate
464
463
  target_name = target_name.upcase
464
+ # The cache is keyed by item_map_key since the same target can exist in multiple scopes
465
+ item_map_key = "#{scope}__#{target_name}__item_to_packet_map"
466
+ cache_time, item_map = @@item_map_cache[item_map_key]
467
+ return item_map if item_map and (Time.now - cache_time) < ITEM_MAP_CACHE_TIMEOUT
465
468
  store = store_for_target(target_name, scope: scope)
466
469
  json_data = store.get(item_map_key)
467
470
  if json_data
@@ -470,7 +473,7 @@ module OpenC3
470
473
  item_map = build_item_to_packet_map(target_name, scope: scope)
471
474
  store.set(item_map_key, JSON.generate(item_map, allow_nan: true))
472
475
  end
473
- @@item_map_cache[target_name] = [Time.now, item_map]
476
+ @@item_map_cache[item_map_key] = [Time.now, item_map]
474
477
  return item_map
475
478
  end
476
479
 
@@ -917,7 +920,7 @@ module OpenC3
917
920
  # Delete item_map
918
921
  item_map_key = "#{@scope}__#{@name}__item_to_packet_map"
919
922
  db_shard_store.del(item_map_key)
920
- @@item_map_cache[@name] = nil
923
+ @@item_map_cache[item_map_key] = nil
921
924
 
922
925
  topic = { kind: 'deleted', type: 'target', name: @name }
923
926
  # The UNKNOWN target doesn't have an associated plugin
@@ -1101,7 +1104,7 @@ module OpenC3
1101
1104
  item_map_key = "#{@scope}__#{@name}__item_to_packet_map"
1102
1105
  item_map = self.class.build_item_to_packet_map(@name, scope: @scope)
1103
1106
  Store.instance(db_shard: @db_shard).set(item_map_key, JSON.generate(item_map, allow_nan: true))
1104
- @@item_map_cache[@name] = [Time.now, item_map]
1107
+ @@item_map_cache[item_map_key] = [Time.now, item_map]
1105
1108
  end
1106
1109
 
1107
1110
  def update_store(system, clear_old: true)
@@ -398,14 +398,19 @@ module OpenC3
398
398
  if @states
399
399
  @states.each do |state_name, state_value|
400
400
  config << " STATE #{state_name.to_s.quote_if_necessary} #{state_value.to_s.quote_if_necessary}"
401
- if @hazardous and @hazardous[state_name]
402
- config << " HAZARDOUS #{@hazardous[state_name].to_s.quote_if_necessary}"
403
- end
404
- if @messages_disabled and @messages_disabled[state_name]
405
- config << " DISABLE_MESSAGES"
406
- end
407
- if @state_colors and @state_colors[state_name]
408
- config << " #{@state_colors[state_name]}"
401
+ # Only write the attributes STATE accepts for this packet type so the config parses back
402
+ if cmd_or_tlm == :TELEMETRY
403
+ if @state_colors and @state_colors[state_name]
404
+ config << " #{@state_colors[state_name]}"
405
+ end
406
+ else
407
+ if @hazardous and @hazardous[state_name]
408
+ config << " HAZARDOUS"
409
+ config << " #{@hazardous[state_name].to_s.quote_if_necessary}" unless @hazardous[state_name].to_s.empty?
410
+ end
411
+ if @messages_disabled and @messages_disabled[state_name]
412
+ config << " DISABLE_MESSAGES"
413
+ end
409
414
  end
410
415
  config << "\n"
411
416
  end
@@ -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_comparison(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
@@ -768,10 +770,10 @@ module OpenC3
768
770
  # Builds a callable which compares a telemetry value against the given comparison string,
769
771
  # e.g. "> 1". Returns nil if there is no comparison, e.g. a block based wait_check().
770
772
  # Raises if the comparison is invalid, e.g. an unsupported operator or unparsable operand.
771
- def _comparison_implementation(comparison_to_eval)
773
+ def _comparison_implementation(target_name, packet_name, item_name, value_type, comparison_to_eval, scope: $openc3_scope, token: $openc3_token)
772
774
  return nil unless comparison_to_eval
773
775
 
774
- operator, operand = extract_operator_and_operand_from_comparison(comparison_to_eval)
776
+ operator, operand = extract_comparison(comparison_to_eval, value_type) { get_item(target_name, packet_name, item_name, scope: scope, token: token) }
775
777
  return nil unless operator
776
778
  lambda { |value| compare_values(value, operator, operand) }
777
779
  end
@@ -783,7 +785,7 @@ module OpenC3
783
785
  raise "ERROR: Invalid comparison to non-ascii value"
784
786
  end
785
787
  # A user supplied block is the condition so the comparison text is never parsed
786
- comparison = block ? nil : _comparison_implementation(comparison_to_eval)
788
+ comparison = block ? nil : _comparison_implementation(target_name, packet_name, item_name, value_type, comparison_to_eval, scope: scope, token: token)
787
789
  _openc3_script_wait_implementation(target_name, packet_name, item_name, value_type, timeout, polling_rate, comparison, scope: scope, token: token, &block)
788
790
  end
789
791
 
@@ -851,14 +853,14 @@ module OpenC3
851
853
  end
852
854
  end
853
855
 
854
- def _check_comparison(target_name, packet_name, item_name, comparison_to_eval, value)
856
+ def _check_comparison(target_name, packet_name, item_name, value_type, comparison_to_eval, value, scope: $openc3_scope, token: $openc3_token)
855
857
  check_str = "CHECK: #{_upcase(target_name, packet_name, item_name)} #{comparison_to_eval}"
856
858
  # Show user the check against a quoted string
857
859
  value_str = value.is_a?(String) ? "'#{value}'" : value
858
860
  value_str = 'nil' if value.nil? # Show user nil value as 'nil'
859
861
  with_value = "with value == #{value_str}"
860
862
 
861
- operator, operand = extract_operator_and_operand_from_comparison(comparison_to_eval)
863
+ operator, operand = extract_comparison(comparison_to_eval, value_type) { get_item(target_name, packet_name, item_name, scope: scope, token: token) }
862
864
  unless _valid_comparison?(value, operator, operand)
863
865
  message = "Invalid comparison for types"
864
866
  if $disconnect