openc3 7.4.1 → 7.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/bin/pipinstall +18 -5
  3. data/bin/pipuninstall +1 -1
  4. data/bin/uvinstall +36 -8
  5. data/data/config/default_plugins.yaml +43 -0
  6. data/data/config/interface_modifiers.yaml +22 -7
  7. data/data/config/item_modifiers.yaml +14 -4
  8. data/data/config/plugins.yaml +1 -1
  9. data/data/config/widgets.yaml +1 -1
  10. data/lib/openc3/api/cmd_api.rb +10 -13
  11. data/lib/openc3/api/interface_api.rb +1 -2
  12. data/lib/openc3/api/limits_api.rb +1 -1
  13. data/lib/openc3/api/metrics_api.rb +26 -1
  14. data/lib/openc3/api/router_api.rb +1 -2
  15. data/lib/openc3/api/tlm_api.rb +6 -10
  16. data/lib/openc3/config/config_parser.rb +3 -1
  17. data/lib/openc3/core_ext/tempfile.rb +2 -0
  18. data/lib/openc3/interfaces/file_interface.rb +6 -1
  19. data/lib/openc3/interfaces/http_client_interface.rb +14 -6
  20. data/lib/openc3/interfaces/interface.rb +27 -3
  21. data/lib/openc3/interfaces/read_queue_interface.rb +331 -0
  22. data/lib/openc3/interfaces/stream_interface.rb +22 -3
  23. data/lib/openc3/interfaces/tcpip_server_interface.rb +13 -0
  24. data/lib/openc3/interfaces/udp_interface.rb +16 -2
  25. data/lib/openc3/interfaces.rb +1 -0
  26. data/lib/openc3/logs/log_writer.rb +13 -4
  27. data/lib/openc3/microservices/decom_microservice.rb +7 -4
  28. data/lib/openc3/microservices/interface_microservice.rb +146 -50
  29. data/lib/openc3/microservices/log_microservice.rb +1 -3
  30. data/lib/openc3/microservices/microservice.rb +25 -1
  31. data/lib/openc3/microservices/plugin_microservice.rb +32 -0
  32. data/lib/openc3/microservices/text_log_microservice.rb +1 -3
  33. data/lib/openc3/models/interface_model.rb +1 -1
  34. data/lib/openc3/models/interface_status_model.rb +6 -0
  35. data/lib/openc3/models/metric_model.rb +52 -4
  36. data/lib/openc3/models/microservice_model.rb +7 -3
  37. data/lib/openc3/models/plugin_model.rb +12 -12
  38. data/lib/openc3/models/python_package_model.rb +3 -13
  39. data/lib/openc3/models/target_model.rb +83 -9
  40. data/lib/openc3/operators/microservice_operator.rb +5 -0
  41. data/lib/openc3/operators/operator.rb +69 -0
  42. data/lib/openc3/packets/packet.rb +10 -4
  43. data/lib/openc3/packets/packet_config.rb +12 -1
  44. data/lib/openc3/packets/packet_item.rb +27 -14
  45. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  46. data/lib/openc3/packets/structure_item.rb +3 -2
  47. data/lib/openc3/script/api_shared.rb +58 -84
  48. data/lib/openc3/script/extract.rb +215 -19
  49. data/lib/openc3/script/plugins.rb +4 -11
  50. data/lib/openc3/script/storage.rb +81 -10
  51. data/lib/openc3/system/system.rb +9 -6
  52. data/lib/openc3/topics/command_decom_topic.rb +1 -1
  53. data/lib/openc3/topics/command_topic.rb +2 -1
  54. data/lib/openc3/topics/router_topic.rb +2 -1
  55. data/lib/openc3/topics/telemetry_decom_topic.rb +1 -1
  56. data/lib/openc3/topics/telemetry_topic.rb +3 -2
  57. data/lib/openc3/topics/topic.rb +154 -2
  58. data/lib/openc3/utilities/cli_generator.rb +67 -15
  59. data/lib/openc3/utilities/config_overlay.rb +35 -17
  60. data/lib/openc3/utilities/default_plugins.rb +33 -0
  61. data/lib/openc3/utilities/local_mode.rb +82 -46
  62. data/lib/openc3/utilities/metric.rb +16 -0
  63. data/lib/openc3/utilities/process_stats.rb +160 -0
  64. data/lib/openc3/utilities/pypi_url.rb +65 -0
  65. data/lib/openc3/utilities/questdb_client.rb +14 -0
  66. data/lib/openc3/utilities/running_script.rb +22 -18
  67. data/lib/openc3/utilities/store_autoload.rb +20 -3
  68. data/lib/openc3/utilities/store_queued.rb +37 -9
  69. data/lib/openc3/utilities/target_file.rb +57 -12
  70. data/lib/openc3/utilities/topic_lag_monitor.rb +193 -0
  71. data/lib/openc3/version.rb +6 -6
  72. data/templates/conversion/conversion.py +6 -2
  73. data/templates/conversion/conversion.rb +5 -0
  74. data/templates/plugin/.github/workflows/playwright.yml +62 -0
  75. data/templates/plugin/.github/workflows/release.yml +45 -0
  76. data/templates/plugin/.github/workflows/unit-tests.yml +35 -0
  77. data/templates/plugin/.gitignore +6 -0
  78. data/templates/plugin/README.md +114 -2
  79. data/templates/plugin/lib/PACKAGE/__init__.py +4 -0
  80. data/templates/plugin/plugin.gemspec +10 -1
  81. data/templates/plugin/pyproject.toml +119 -0
  82. data/templates/tool_angular/package.json +2 -2
  83. data/templates/tool_angular/pnpm-workspace.yaml +16 -0
  84. data/templates/tool_react/package.json +1 -1
  85. data/templates/tool_react/pnpm-workspace.yaml +16 -0
  86. data/templates/tool_svelte/package.json +2 -1
  87. data/templates/tool_svelte/pnpm-workspace.yaml +20 -0
  88. data/templates/tool_vue/package.json +3 -3
  89. data/templates/tool_vue/pnpm-workspace.yaml +16 -0
  90. data/templates/widget/package.json +2 -2
  91. data/templates/widget/pnpm-workspace.yaml +16 -0
  92. metadata +16 -1
@@ -42,7 +42,7 @@ module OpenC3
42
42
  class InterfaceCmdHandlerThread
43
43
  include InterfaceDecomCommon
44
44
 
45
- def initialize(interface, tlm, logger: nil, metric: nil, db_shard: 0, scope:)
45
+ def initialize(interface, tlm, logger: nil, metric: nil, lag_monitor: nil, db_shard: 0, scope:)
46
46
  @interface = interface
47
47
  @tlm = tlm
48
48
  @scope = scope
@@ -56,6 +56,7 @@ module OpenC3
56
56
  @logger = logger
57
57
  @logger = Logger unless @logger
58
58
  @metric = metric
59
+ @lag_monitor = lag_monitor || TopicLagMonitor.new(name: "#{@scope}__INTERFACE__#{@interface.name}", logger: @logger, metric: @metric)
59
60
  @count = 0
60
61
  @directive_count = 0
61
62
  @metric.set(name: 'interface_directive_total', value: @directive_count, type: 'counter') if @metric
@@ -86,9 +87,7 @@ module OpenC3
86
87
  OpenC3.with_context(msg_hash) do
87
88
  release_critical = false
88
89
  critical_model = nil
89
- msgid_seconds_from_epoch = msg_id.split('-')[0].to_i / 1000.0
90
- delta = Time.now.to_f - msgid_seconds_from_epoch
91
- @metric.set(name: 'interface_topic_delta_seconds', value: delta, type: 'gauge', unit: 'seconds', help: 'Delta time between data written to stream and interface cmd start') if @metric
90
+ @lag_monitor.record(topic, msg_id, metric_name: 'interface_topic_delta_seconds', help: 'Delta time between data written to stream and interface cmd start')
92
91
 
93
92
  if topic == "OPENC3__SYSTEM__EVENTS"
94
93
  msg = JSON.parse(msg_hash['event'])
@@ -113,7 +112,13 @@ module OpenC3
113
112
  @logger.info "#{@interface.name}: Connect requested"
114
113
  params = []
115
114
  if msg_hash['params']
116
- params = JSON.parse(msg_hash['params'], allow_nan: true, create_additions: true)
115
+ begin
116
+ params = JSON.parse(msg_hash['params'], allow_nan: true, create_additions: true)
117
+ rescue => e
118
+ # Reject malformed params rather than killing the handler thread
119
+ @logger.error "#{@interface.name}: Invalid connect params: #{e.message}"
120
+ next e.message
121
+ end
117
122
  end
118
123
  @interface = @tlm.attempting(*params)
119
124
  next 'SUCCESS'
@@ -156,8 +161,8 @@ module OpenC3
156
161
  next 'SUCCESS'
157
162
  end
158
163
  if msg_hash.key?('interface_cmd')
159
- params = JSON.parse(msg_hash['interface_cmd'], allow_nan: true, create_additions: true)
160
164
  begin
165
+ params = JSON.parse(msg_hash['interface_cmd'], allow_nan: true, create_additions: true)
161
166
  @logger.info "#{@interface.name}: interface_cmd: #{params['cmd_name']} #{params['cmd_params'].join(' ')}"
162
167
  @interface.interface_cmd(params['cmd_name'], *params['cmd_params'])
163
168
  InterfaceStatusModel.set(@interface.as_json(), queued: true, scope: @scope)
@@ -168,8 +173,8 @@ module OpenC3
168
173
  next 'SUCCESS'
169
174
  end
170
175
  if msg_hash.key?('protocol_cmd')
171
- params = JSON.parse(msg_hash['protocol_cmd'], allow_nan: true, create_additions: true)
172
176
  begin
177
+ params = JSON.parse(msg_hash['protocol_cmd'], allow_nan: true, create_additions: true)
173
178
  @logger.info "#{@interface.name}: protocol_cmd: #{params['cmd_name']} #{params['cmd_params'].join(' ')} read_write: #{params['read_write']} index: #{params['index']}"
174
179
  @interface.protocol_cmd(params['cmd_name'], *params['cmd_params'], read_write: params['read_write'], index: params['index'])
175
180
  InterfaceStatusModel.set(@interface.as_json(), queued: true, scope: @scope)
@@ -232,21 +237,28 @@ module OpenC3
232
237
 
233
238
  target_name = msg_hash['target_name']
234
239
  if target_name and not @interface.cmd_target_enabled[target_name]
240
+ # A released critical command was already acked as pending so it must get a result
241
+ next "Target #{target_name} is disabled" if release_critical
235
242
  next nil # Return and don't ack given target_name if disabled
236
243
  end
237
244
 
238
245
  cmd_name = msg_hash['cmd_name']
239
- manual = ConfigParser.handle_true_false(msg_hash['manual'])
246
+ manual = ConfigParser.handle_true_false(msg_hash['manual'], default: false)
240
247
  cmd_params = nil
241
248
  range_check = true
242
249
  raw = false
243
250
  cmd_buffer = nil
244
251
  hazardous_check = nil
245
252
  if msg_hash['cmd_params']
246
- cmd_params = JSON.parse(msg_hash['cmd_params'], allow_nan: true, create_additions: true)
247
- range_check = ConfigParser.handle_true_false(msg_hash['range_check'])
248
- raw = ConfigParser.handle_true_false(msg_hash['raw'])
249
- hazardous_check = ConfigParser.handle_true_false(msg_hash['hazardous_check'])
253
+ begin
254
+ cmd_params = JSON.parse(msg_hash['cmd_params'], allow_nan: true, create_additions: true)
255
+ rescue => e
256
+ @logger.error "#{@interface.name}: Invalid cmd_params: #{e.message}"
257
+ next "Invalid cmd_params: #{e.message}"
258
+ end
259
+ range_check = ConfigParser.handle_true_false(msg_hash['range_check'], default: true)
260
+ raw = ConfigParser.handle_true_false(msg_hash['raw'], default: false)
261
+ hazardous_check = ConfigParser.handle_true_false(msg_hash['hazardous_check'], default: true)
250
262
  elsif msg_hash['cmd_buffer']
251
263
  cmd_buffer = msg_hash['cmd_buffer']
252
264
  end
@@ -269,12 +281,16 @@ module OpenC3
269
281
  raise "Invalid command received:\n #{msg_hash}"
270
282
  end
271
283
 
272
- if @interface.cmd_target_enabled[command.target_name]
284
+ # An unidentified buffer is sent as UNKNOWN on behalf of the target it was sent to
285
+ enabled_target_name = command.target_name
286
+ enabled_target_name = target_name if enabled_target_name == 'UNKNOWN' and target_name
287
+ if @interface.cmd_target_enabled[enabled_target_name]
273
288
  orig_command = System.commands.packet(command.target_name, command.packet_name)
274
289
  orig_command.received_count = TargetModel.increment_command_count(command.target_name, command.packet_name, 1, scope: @scope)
275
290
  command.received_count = orig_command.received_count
276
291
  command.received_time = Time.now
277
292
  else
293
+ next "Target #{command.target_name} is disabled" if release_critical
278
294
  next nil # Don't ack disabled targets
279
295
  end
280
296
  rescue RangeError => e
@@ -322,7 +338,7 @@ module OpenC3
322
338
  end
323
339
  end
324
340
 
325
- validate = ConfigParser.handle_true_false(msg_hash['validate'])
341
+ validate = ConfigParser.handle_true_false(msg_hash['validate'], default: true)
326
342
  begin
327
343
  if @interface.connected?
328
344
  result = true
@@ -344,7 +360,7 @@ module OpenC3
344
360
  @count += 1
345
361
  @metric.set(name: 'interface_cmd_total', value: @count, type: 'counter') if @metric
346
362
 
347
- log_message = ConfigParser.handle_true_false(msg_hash['log_message'])
363
+ log_message = ConfigParser.handle_true_false(msg_hash['log_message'], default: true)
348
364
  if log_message
349
365
  @logger.info(msg_hash['cmd_string'], user: msg_hash['username'], scope: @scope)
350
366
  end
@@ -393,7 +409,7 @@ module OpenC3
393
409
  end
394
410
 
395
411
  class RouterTlmHandlerThread
396
- def initialize(router, tlm, logger: nil, metric: nil, db_shard: 0, scope:)
412
+ def initialize(router, tlm, logger: nil, metric: nil, lag_monitor: nil, db_shard: 0, scope:)
397
413
  @router = router
398
414
  @tlm = tlm
399
415
  @scope = scope
@@ -401,6 +417,7 @@ module OpenC3
401
417
  @logger = logger
402
418
  @logger = Logger unless @logger
403
419
  @metric = metric
420
+ @lag_monitor = lag_monitor || TopicLagMonitor.new(name: "#{@scope}__ROUTER__#{@router.name}", logger: @logger, metric: @metric)
404
421
  @count = 0
405
422
  @directive_count = 0
406
423
  @metric.set(name: 'router_directive_total', value: @directive_count, type: 'counter') if @metric
@@ -428,9 +445,7 @@ module OpenC3
428
445
 
429
446
  def run
430
447
  RouterTopic.receive_telemetry(@router, scope: @scope, db_shard: @db_shard) do |topic, msg_id, msg_hash, _redis|
431
- msgid_seconds_from_epoch = msg_id.split('-')[0].to_i / 1000.0
432
- delta = Time.now.to_f - msgid_seconds_from_epoch
433
- @metric.set(name: 'router_topic_delta_seconds', value: delta, type: 'gauge', unit: 'seconds', help: 'Delta time between data written to stream and router tlm start') if @metric
448
+ @lag_monitor.record(topic, msg_id, metric_name: 'router_topic_delta_seconds', help: 'Delta time between data written to stream and router tlm start')
434
449
 
435
450
  # Check for commands to the router itself
436
451
  if /CMD}ROUTER/.match?(topic)
@@ -446,7 +461,13 @@ module OpenC3
446
461
  @logger.info "#{@router.name}: Connect requested"
447
462
  params = []
448
463
  if msg_hash['params']
449
- params = JSON.parse(msg_hash['params'], allow_nan: true, create_additions: true)
464
+ begin
465
+ params = JSON.parse(msg_hash['params'], allow_nan: true, create_additions: true)
466
+ rescue => e
467
+ # Reject malformed params rather than killing the handler thread
468
+ @logger.error "#{@router.name}: Invalid connect params: #{e.message}"
469
+ next e.message
470
+ end
450
471
  end
451
472
  @router = @tlm.attempting(*params)
452
473
  next 'SUCCESS'
@@ -555,6 +576,14 @@ module OpenC3
555
576
 
556
577
  class InterfaceMicroservice < Microservice
557
578
  UNKNOWN_BYTES_TO_PRINT = 16
579
+ DEFAULT_UPDATE_INTERVAL = 0.1 # seconds
580
+ # Most distinct unmapped target / packet names to remember warning about
581
+ UNMAPPED_TLM_WARNED_MAX = 1000
582
+
583
+ # Interfaces never use descriptions so don't keep them in memory
584
+ def load_descriptions?
585
+ false
586
+ end
558
587
 
559
588
  def initialize(name)
560
589
  @mutex = Mutex.new
@@ -591,28 +620,35 @@ module OpenC3
591
620
  RouterStatusModel.set(@interface.as_json(), scope: @scope)
592
621
  end
593
622
 
594
- @queued = false
623
+ # Insertion ordered so the oldest key can be evicted (see handle_packet)
624
+ @unmapped_tlm_warned = {}
625
+ # Queue Redis writes by default and flush them every update_interval seconds
626
+ update_interval = DEFAULT_UPDATE_INTERVAL
595
627
  @interface.options.each do |option_name, option_values|
596
628
  # OPTIMIZE_THROUGHPUT was changed to UPDATE_INTERVAL to better represent the setting
597
629
  if option_name.upcase == 'UPDATE_INTERVAL' or option_name.upcase == 'OPTIMIZE_THROUGHPUT'
598
- @queued = true
599
630
  update_interval = option_values[0].to_f
600
- EphemeralStoreQueued.instance.set_update_interval(update_interval)
601
- StoreQueued.instance.set_update_interval(update_interval)
602
631
  end
603
632
  if option_name.upcase == 'SYNC_PACKET_COUNT_DELAY_SECONDS'
604
633
  TargetModel.sync_packet_count_delay_seconds = option_values[0].to_f
605
634
  end
606
635
  end
636
+ # An UPDATE_INTERVAL of 0 (or less) disables queuing and writes directly to Redis
637
+ @queued = update_interval > 0.0
638
+ if @queued
639
+ # Apply to every db_shard since writes are routed to the shard of each target
640
+ EphemeralStoreQueued.set_update_interval_all_shards(update_interval)
641
+ StoreQueued.set_update_interval_all_shards(update_interval)
642
+ end
607
643
 
608
644
  @interface_thread_sleeper = Sleeper.new
609
645
  @cancel_thread = false
610
646
  @connection_failed_messages = []
611
647
  @connection_lost_messages = []
612
648
  if @interface_or_router == 'INTERFACE'
613
- @handler_thread = InterfaceCmdHandlerThread.new(@interface, self, logger: @logger, metric: @metric, db_shard: @db_shard, scope: @scope)
649
+ @handler_thread = InterfaceCmdHandlerThread.new(@interface, self, logger: @logger, metric: @metric, lag_monitor: topic_lag_monitor, db_shard: @db_shard, scope: @scope)
614
650
  else
615
- @handler_thread = RouterTlmHandlerThread.new(@interface, self, logger: @logger, metric: @metric, db_shard: @db_shard, scope: @scope)
651
+ @handler_thread = RouterTlmHandlerThread.new(@interface, self, logger: @logger, metric: @metric, lag_monitor: topic_lag_monitor, db_shard: @db_shard, scope: @scope)
616
652
  end
617
653
  @handler_thread.start
618
654
  end
@@ -630,9 +666,17 @@ module OpenC3
630
666
  return @interface
631
667
  end
632
668
 
669
+ # A user requested (re)connect starts fresh so backtraces are logged again
670
+ clear_connection_messages()
633
671
  attempt_connection(*params)
634
672
  end
635
673
 
674
+ # Forget which errors have already had their backtrace logged
675
+ def clear_connection_messages
676
+ @connection_failed_messages.clear
677
+ @connection_lost_messages.clear
678
+ end
679
+
636
680
  # Sets the state to 'ATTEMPTING', first rebuilding the interface/router if
637
681
  # parameters are given, so the run method performs the actual connection.
638
682
  # Unlike attempting() this always transitions. The reconnect path in
@@ -720,6 +764,7 @@ module OpenC3
720
764
  else
721
765
  @metric.set(name: 'router_cmd_total', value: @count, type: 'counter')
722
766
  end
767
+ set_read_queue_bytes_metric()
723
768
  else
724
769
  @logger.info "#{@interface.name}: Internal disconnect requested (returned nil)"
725
770
  handle_connection_lost()
@@ -753,31 +798,56 @@ module OpenC3
753
798
  @logger.info "#{@interface.name}: Stopped packet reading"
754
799
  end
755
800
 
801
+ # Report the bytes buffered on the read queue. Called after each packet and
802
+ # after a disconnect (which discards the queue) so the gauge doesn't stay
803
+ # stuck at its last value while no packets are flowing.
804
+ def set_read_queue_bytes_metric
805
+ if @interface_or_router == 'INTERFACE'
806
+ @metric.set(name: 'interface_read_queue_bytes', value: @interface.read_queue_bytes, type: 'gauge',
807
+ unit: 'bytes', help: 'Bytes buffered on the interface read queue waiting to be processed')
808
+ else
809
+ @metric.set(name: 'router_read_queue_bytes', value: @interface.read_queue_bytes, type: 'gauge',
810
+ unit: 'bytes', help: 'Bytes buffered on the router read queue waiting to be processed')
811
+ end
812
+ end
813
+
756
814
  def handle_packet(packet)
757
815
  # Skip status update if stop() has been called to avoid re-creating the status model
758
816
  InterfaceStatusModel.set(@interface.as_json(), queued: true, scope: @scope) unless @cancel_thread
759
817
  packet.received_time = Time.now.sys unless packet.received_time
760
818
 
819
+ if packet.identified?
820
+ unless @interface.tlm_target_names.include?(packet.target_name)
821
+ # Trust the identity but drop it because the target isn't mapped to this interface
822
+ key = "#{packet.target_name} #{packet.packet_name}"
823
+ unless @unmapped_tlm_warned.key?(key)
824
+ # The names come from the wire so bound how many are remembered.
825
+ # Evict the oldest rather than stop adding so every new name still warns.
826
+ @unmapped_tlm_warned.delete(@unmapped_tlm_warned.first[0]) if @unmapped_tlm_warned.length >= UNMAPPED_TLM_WARNED_MAX
827
+ @unmapped_tlm_warned[key] = true
828
+ @logger.warn "#{@interface.name}: Dropping telemetry for unmapped target: #{key}"
829
+ end
830
+ return
831
+ end
832
+ unless known_tlm_packet?(packet)
833
+ # Packet identified but we don't know about it
834
+ # Clear packet_name and target_name and try to identify
835
+ @logger.warn "#{@interface.name}: Received unknown identified telemetry: #{packet.target_name} #{packet.packet_name}"
836
+ packet.target_name = nil
837
+ packet.packet_name = nil
838
+ end
839
+ end
840
+
761
841
  if packet.stored
762
842
  # Stored telemetry does not update the current value table
763
843
  identified_packet = System.telemetry.identify_and_define_packet(packet, @interface.tlm_target_names)
764
844
  else
765
845
  # Identify and update packet
766
846
  if packet.identified?
767
- begin
768
- # Preidentifed packet - place it into the current value table
769
- identified_packet = System.telemetry.update!(packet.target_name,
770
- packet.packet_name,
771
- packet.buffer)
772
- rescue RuntimeError
773
- # Packet identified but we don't know about it
774
- # Clear packet_name and target_name and try to identify
775
- @logger.warn "#{@interface.name}: Received unknown identified telemetry: #{packet.target_name} #{packet.packet_name}"
776
- packet.target_name = nil
777
- packet.packet_name = nil
778
- identified_packet = System.telemetry.identify!(packet.buffer,
779
- @interface.tlm_target_names)
780
- end
847
+ # Preidentifed packet - place it into the current value table
848
+ identified_packet = System.telemetry.update!(packet.target_name,
849
+ packet.packet_name,
850
+ packet.buffer)
781
851
  else
782
852
  # Packet needs to be identified
783
853
  identified_packet = System.telemetry.identify!(packet.buffer,
@@ -791,13 +861,21 @@ module OpenC3
791
861
  identified_packet.extra = packet.extra
792
862
  packet = identified_packet
793
863
  else
794
- unknown_packet = System.telemetry.update!('UNKNOWN', 'UNKNOWN', packet.buffer)
864
+ if packet.stored
865
+ # Stored telemetry does not update the current value table
866
+ unknown_packet = System.telemetry.packet('UNKNOWN', 'UNKNOWN').clone
867
+ unknown_packet.buffer = packet.buffer
868
+ else
869
+ unknown_packet = System.telemetry.update!('UNKNOWN', 'UNKNOWN', packet.buffer)
870
+ end
795
871
  unknown_packet.received_time = packet.received_time
796
872
  unknown_packet.stored = packet.stored
797
873
  unknown_packet.extra = packet.extra
798
874
  packet = unknown_packet
799
- json_hash = CvtModel.build_json_from_packet(packet)
800
- CvtModel.set(json_hash, target_name: packet.target_name, packet_name: packet.packet_name, queued: @queued, scope: @scope)
875
+ unless packet.stored
876
+ json_hash = CvtModel.build_json_from_packet(packet)
877
+ CvtModel.set(json_hash, target_name: packet.target_name, packet_name: packet.packet_name, queued: @queued, scope: @scope)
878
+ end
801
879
  num_bytes_to_print = [UNKNOWN_BYTES_TO_PRINT, packet.length].min
802
880
  data = packet.buffer(false)[0..(num_bytes_to_print - 1)]
803
881
  prefix = data.each_byte.map { | byte | sprintf("%02X", byte) }.join()
@@ -811,6 +889,14 @@ module OpenC3
811
889
  end
812
890
  end
813
891
 
892
+ # @return [Boolean] Whether a pre-identified packet names a defined packet
893
+ def known_tlm_packet?(packet)
894
+ System.telemetry.packet(packet.target_name, packet.packet_name)
895
+ true
896
+ rescue RuntimeError
897
+ false
898
+ end
899
+
814
900
  def handle_connection_failed(connection, connect_error)
815
901
  @error = connect_error
816
902
  @logger.error "#{@interface.name}: Connection #{connection} failed due to #{connect_error.formatted(false, false)}"
@@ -824,9 +910,11 @@ module OpenC3
824
910
  if RuntimeError === connect_error and (connect_error.message =~ /canceled/ or connect_error.message =~ /timeout/)
825
911
  # Do not write an exception file for these extremely common cases
826
912
  else
827
- @logger.error "#{@interface.name}: #{connect_error.formatted}"
828
- unless @connection_failed_messages.include?(connect_error.message)
829
- @connection_failed_messages << connect_error.message
913
+ # Only log the backtrace the first time we see each distinct error
914
+ key = [connect_error.class, connect_error.message]
915
+ unless @connection_failed_messages.include?(key)
916
+ @logger.error "#{@interface.name}: #{connect_error.formatted}"
917
+ @connection_failed_messages << key
830
918
  end
831
919
  end
832
920
  end
@@ -844,9 +932,11 @@ module OpenC3
844
932
  when Errno::ECONNABORTED, Errno::ECONNRESET, Errno::ETIMEDOUT, Errno::EBADF, Errno::ENOTSOCK, IOError
845
933
  # Do not write an exception file for these extremely common cases
846
934
  else
847
- @logger.error "#{@interface.name}: #{err.formatted}"
848
- unless @connection_lost_messages.include?(err.message)
849
- @connection_lost_messages << err.message
935
+ # Only log the backtrace the first time we see each distinct error
936
+ key = [err.class, err.message]
937
+ unless @connection_lost_messages.include?(key)
938
+ @logger.error "#{@interface.name}: #{err.formatted}"
939
+ @connection_lost_messages << key
850
940
  end
851
941
  end
852
942
  else
@@ -905,6 +995,7 @@ module OpenC3
905
995
  rescue => e
906
996
  @logger.error "Disconnect: #{@interface.name}: #{e.formatted}"
907
997
  end
998
+ set_read_queue_bytes_metric() if @metric
908
999
 
909
1000
  # If the interface is set to auto_reconnect then delay so the thread
910
1001
  # can come back around and allow the interface a chance to reconnect.
@@ -940,7 +1031,12 @@ module OpenC3
940
1031
  @handler_thread.stop if @handler_thread
941
1032
  @interface_thread_sleeper.cancel if @interface_thread_sleeper
942
1033
  if @interface
943
- @interface.disconnect
1034
+ begin
1035
+ @interface.disconnect
1036
+ rescue => e
1037
+ # Still clean up the status and stream logs if disconnect fails
1038
+ @logger.error "#{@interface.name}: Disconnect failed during stop: #{e.formatted}"
1039
+ end
944
1040
  if @interface_or_router == 'INTERFACE'
945
1041
  valid_interface = InterfaceStatusModel.get_model(name: @interface.name, scope: @scope)
946
1042
  else
@@ -95,9 +95,7 @@ module OpenC3
95
95
  end
96
96
 
97
97
  def log_data(topic, msg_id, msg_hash, redis)
98
- msgid_seconds_from_epoch = msg_id.split('-')[0].to_i / 1000.0
99
- delta = Time.now.to_f - msgid_seconds_from_epoch
100
- @metric.set(name: 'log_topic_delta_seconds', value: delta, type: 'gauge', unit: 'seconds', help: 'Delta time between data written to stream and log start')
98
+ update_topic_lag(topic, msg_id, metric_name: 'log_topic_delta_seconds', help: 'Delta time between data written to stream and log start')
101
99
 
102
100
  topic_split = topic.gsub(/{|}/, '').split("__") # Remove the redis hashtag curly braces
103
101
  target_name = topic_split[2]
@@ -26,6 +26,7 @@ OpenC3.require_file 'openc3/utilities/secrets'
26
26
  OpenC3.require_file 'openc3/utilities/sleeper'
27
27
  OpenC3.require_file 'openc3/utilities/open_telemetry'
28
28
  OpenC3.require_file 'openc3/utilities/thread_manager'
29
+ OpenC3.require_file 'openc3/utilities/topic_lag_monitor'
29
30
  OpenC3.require_file 'openc3/models/microservice_model'
30
31
  OpenC3.require_file 'openc3/models/microservice_status_model'
31
32
  OpenC3.require_file 'tmpdir'
@@ -133,7 +134,7 @@ module OpenC3
133
134
  @target_names = @config["target_names"]
134
135
  @target_names ||= []
135
136
  # NOTE: setup_targets doesn't do anything if @target_names is empty
136
- System.setup_targets(@target_names, @temp_dir, scope: @scope) unless is_plugin
137
+ System.setup_targets(@target_names, @temp_dir, scope: @scope, descriptions: load_descriptions?) unless is_plugin
137
138
 
138
139
  # Use at_exit to shutdown cleanly no matter how we die
139
140
  at_exit do
@@ -231,6 +232,12 @@ module OpenC3
231
232
  shutdown()
232
233
  end
233
234
 
235
+ # Whether System keeps packet and item descriptions. Subclasses that never
236
+ # use them override this to return false to reduce memory.
237
+ def load_descriptions?
238
+ true
239
+ end
240
+
234
241
  def shutdown(state = 'STOPPED')
235
242
  return if @shutdown_complete
236
243
  begin
@@ -263,6 +270,23 @@ module OpenC3
263
270
  topic_offsets[@microservice_topic] = "0-0" # Always get all available
264
271
  end
265
272
 
273
+ # Update the lag metric for a message read from a topic and notify users
274
+ # if this microservice is falling behind
275
+ #
276
+ # @param topic [String] Redis stream the message was read from
277
+ # @param msg_id [String] Redis stream id of the message
278
+ # @param metric_name [String] Gauge metric to update with the lag
279
+ # @param help [String] Help text for the gauge metric
280
+ # @return [Float] The lag in seconds
281
+ def update_topic_lag(topic, msg_id, metric_name:, help:)
282
+ topic_lag_monitor.record(topic, msg_id, metric_name: metric_name, help: help)
283
+ end
284
+
285
+ # @return [TopicLagMonitor] Lag monitor shared by this microservice's topic readers
286
+ def topic_lag_monitor
287
+ @topic_lag_monitor ||= TopicLagMonitor.new(name: @name, logger: @logger || Logger, metric: @metric)
288
+ end
289
+
266
290
  # Returns if the command was handled
267
291
  def microservice_cmd(topic, msg_id, msg_hash, _redis)
268
292
  command = msg_hash['command']
@@ -35,6 +35,38 @@ module OpenC3
35
35
  # Its ok if this fails
36
36
  end
37
37
 
38
+ # exec() below replaces this process image, so the Metric update thread
39
+ # this bootstrap started dies without ever running shutdown. Whatever it
40
+ # managed to publish would then sit in Redis untouched until it expired,
41
+ # looking for all the world like a live microservice pinned at whatever
42
+ # cpu the bootstrap itself burned loading the OpenC3 gem. Clear it out and
43
+ # stop the thread before handing the process over to the real cmd. A cmd
44
+ # that is itself an OpenC3::Microservice builds a new Metric and starts
45
+ # publishing again; one that isn't gets its cpu and memory reported by the
46
+ # operator instead (see Operator#publish_process_metrics).
47
+ begin
48
+ @metric.shutdown
49
+ # Stop and drain pending writes before deleting the row, otherwise a
50
+ # queued bootstrap update can recreate it before exec. Any later write
51
+ # through this StoreQueued goes straight to Redis instead of the queue.
52
+ MetricModel.store_queued.instance(db_shard: @db_shard).shutdown
53
+ MetricModel.new(name: @name, scope: @scope, db_shard: @db_shard).destroy
54
+ rescue Exception => e
55
+ @logger.warn("Failed to clear bootstrap metrics for #{@name}: #{e.message}")
56
+ end
57
+
58
+ # Microservice.run published INITIALIZED and then set @state to RUNNING
59
+ # in memory only. A plugin has no status thread, and exec() below means
60
+ # nothing here gets another chance to write it, so a cmd that isn't
61
+ # itself an OpenC3::Microservice (Rails app, python script, any other
62
+ # binary) would show INITIALIZED forever. Publish RUNNING now. A cmd that
63
+ # is an OpenC3::Microservice overwrites this with its own status.
64
+ begin
65
+ MicroserviceStatusModel.set(as_json(), scope: @scope)
66
+ rescue Exception => e
67
+ @logger.warn("Failed to publish status for #{@name}: #{e.message}")
68
+ end
69
+
38
70
  # Fortify: Process Control
39
71
  # This is dangerous! However, plugins need to be able to run whatever they want.
40
72
  # Only admins can install plugins and they need to be vetted for content.
@@ -82,9 +82,7 @@ module OpenC3
82
82
  end
83
83
 
84
84
  def log_data(topic, msg_id, msg_hash, redis)
85
- msgid_seconds_from_epoch = msg_id.split('-')[0].to_i / 1000.0
86
- delta = Time.now.to_f - msgid_seconds_from_epoch
87
- @metric.set(name: 'text_log_topic_delta_seconds', value: delta, type: 'gauge', unit: 'seconds', help: 'Delta time between data written to stream and text log start')
85
+ update_topic_lag(topic, msg_id, metric_name: 'text_log_topic_delta_seconds', help: 'Delta time between data written to stream and text log start')
88
86
  @tlws[topic].write(msg_hash["time"].to_i, msg_hash.as_json(allow_nan: true).to_json(allow_nan: true), topic, msg_id)
89
87
  @count += 1
90
88
  rescue => err
@@ -291,7 +291,7 @@ module OpenC3
291
291
  elsif enabled == 'DISABLED'
292
292
  return false
293
293
  else
294
- raise parser.error("MAP_TARGET enabled state must be ENABLED or DISABLED.", usage)
294
+ raise parser.error("#{parser.keyword} enabled state must be ENABLED or DISABLED.", "#{parser.keyword} <Target Name> <ENABLED/DISABLED>")
295
295
  end
296
296
  end
297
297
  end
@@ -62,6 +62,12 @@ module OpenC3
62
62
  _db_sharded_get("#{scope}__#{_get_key}", name: name, scope: scope)
63
63
  end
64
64
 
65
+ # Like get but returns default instead of nil when no status exists
66
+ def self.get_or_default(name:, scope:, default: { 'state' => 'UNKNOWN' })
67
+ status = get(name: name, scope: scope)
68
+ status.nil? ? default : status
69
+ end
70
+
65
71
  def self.names(scope:)
66
72
  _db_sharded_names("#{scope}__#{_get_key}", scope: scope)
67
73
  end
@@ -23,6 +23,13 @@ module OpenC3
23
23
  include DbShardedModel
24
24
 
25
25
  PRIMARY_KEY = '__openc3__metric'.freeze
26
+ # Process level metrics (cpu, memory) reported by the operator on behalf of
27
+ # the microservices it spawned. These live in their own hash so that the
28
+ # operator and the microservice are never two writers of one field - each
29
+ # write replaces the whole values hash, so sharing a field would have them
30
+ # clobbering each other every cycle. The two are merged back together on
31
+ # read by get/names/all, so callers see a single row per microservice.
32
+ PROCESS_PRIMARY_KEY = '__openc3__process_metric'.freeze
26
33
  METRIC_EXPIRE_SECONDS = 3600 # Expire metrics after 1 hour
27
34
 
28
35
  attr_accessor :values
@@ -47,17 +54,48 @@ module OpenC3
47
54
  # NOTE: The following three class methods are used by the ModelController
48
55
  # and are reimplemented to enable various Model class methods to work
49
56
  def self.get(name:, scope:)
50
- _db_sharded_get("#{scope}#{PRIMARY_KEY}", name: name, scope: scope)
57
+ _merge_entries(
58
+ _db_sharded_get("#{scope}#{PRIMARY_KEY}", name: name, scope: scope),
59
+ _db_sharded_get("#{scope}#{PROCESS_PRIMARY_KEY}", name: name, scope: scope)
60
+ )
51
61
  end
52
62
 
53
63
  def self.names(scope:)
54
- _db_sharded_names("#{scope}#{PRIMARY_KEY}", scope: scope)
64
+ names = _db_sharded_names("#{scope}#{PRIMARY_KEY}", scope: scope)
65
+ names.concat(_db_sharded_names("#{scope}#{PROCESS_PRIMARY_KEY}", scope: scope))
66
+ names.uniq.sort
55
67
  end
56
68
 
57
69
  def self.all(scope:)
70
+ result = _db_sharded_all("#{scope}#{PRIMARY_KEY}", scope: scope)
71
+ _db_sharded_all("#{scope}#{PROCESS_PRIMARY_KEY}", scope: scope).each do |name, process_entry|
72
+ result[name] = _merge_entries(result[name], process_entry)
73
+ end
74
+ result
75
+ end
76
+
77
+ # Only the metrics each microservice reported about itself, without the
78
+ # operator's process metrics. Their updated_at is when the microservice
79
+ # last published, which the merged rows from all can't tell you because
80
+ # the operator keeps refreshing them while the process is alive.
81
+ def self.all_self_reported(scope:)
58
82
  _db_sharded_all("#{scope}#{PRIMARY_KEY}", scope: scope)
59
83
  end
60
84
 
85
+ # Combine a microservice's self reported metrics with the process metrics
86
+ # the operator reported for it. The microservice's own values win on a key
87
+ # collision - a process that can measure itself knows better than the
88
+ # operator watching from outside.
89
+ def self._merge_entries(entry, process_entry)
90
+ return entry unless process_entry
91
+ return process_entry unless entry
92
+
93
+ entry['values'] = (process_entry['values'] || {}).merge(entry['values'] || {})
94
+ updated_at = [entry['updated_at'], process_entry['updated_at']].compact.max
95
+ entry['updated_at'] = updated_at if updated_at
96
+ entry
97
+ end
98
+
61
99
  # Sets (updates) the redis hash of this model
62
100
  # Queued defaults to true for MetricModel
63
101
  def self.set(json, scope:, queued: true)
@@ -66,13 +104,23 @@ module OpenC3
66
104
  self.new(**json).create(force: true, queued: queued, expire_seconds: METRIC_EXPIRE_SECONDS)
67
105
  end
68
106
 
107
+ # Sets (updates) the process metrics reported by the operator for a
108
+ # microservice it spawned. See PROCESS_PRIMARY_KEY.
109
+ def self.set_process(json, scope:, queued: true)
110
+ json[:scope] = scope
111
+ json.transform_keys!(&:to_sym)
112
+ json[:primary_key] = PROCESS_PRIMARY_KEY
113
+ self.new(**json).create(force: true, queued: queued, expire_seconds: METRIC_EXPIRE_SECONDS)
114
+ end
115
+
69
116
  def self.destroy(scope:, name:)
70
117
  db_shard = _db_shard_for_name(name, scope: scope)
71
118
  store.instance(db_shard: db_shard).hdel("#{scope}#{PRIMARY_KEY}", name)
119
+ store.instance(db_shard: db_shard).hdel("#{scope}#{PROCESS_PRIMARY_KEY}", name)
72
120
  end
73
121
 
74
- def initialize(name:, values: {}, db_shard: 0, scope:)
75
- super("#{scope}#{PRIMARY_KEY}", name: name, scope: scope)
122
+ def initialize(name:, values: {}, db_shard: 0, scope:, primary_key: PRIMARY_KEY)
123
+ super("#{scope}#{primary_key}", name: name, scope: scope)
76
124
  @values = values
77
125
  @db_shard = db_shard.to_i
78
126
  end