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.
- checksums.yaml +4 -4
- data/bin/pipinstall +18 -5
- data/bin/pipuninstall +1 -1
- data/bin/uvinstall +36 -8
- data/data/config/default_plugins.yaml +43 -0
- data/data/config/interface_modifiers.yaml +22 -7
- data/data/config/item_modifiers.yaml +14 -4
- data/data/config/plugins.yaml +1 -1
- data/data/config/widgets.yaml +1 -1
- data/lib/openc3/api/cmd_api.rb +10 -13
- data/lib/openc3/api/interface_api.rb +1 -2
- data/lib/openc3/api/limits_api.rb +1 -1
- data/lib/openc3/api/metrics_api.rb +26 -1
- data/lib/openc3/api/router_api.rb +1 -2
- data/lib/openc3/api/tlm_api.rb +6 -10
- data/lib/openc3/config/config_parser.rb +3 -1
- data/lib/openc3/core_ext/tempfile.rb +2 -0
- data/lib/openc3/interfaces/file_interface.rb +6 -1
- data/lib/openc3/interfaces/http_client_interface.rb +14 -6
- data/lib/openc3/interfaces/interface.rb +27 -3
- data/lib/openc3/interfaces/read_queue_interface.rb +331 -0
- data/lib/openc3/interfaces/stream_interface.rb +22 -3
- data/lib/openc3/interfaces/tcpip_server_interface.rb +13 -0
- data/lib/openc3/interfaces/udp_interface.rb +16 -2
- data/lib/openc3/interfaces.rb +1 -0
- data/lib/openc3/logs/log_writer.rb +13 -4
- data/lib/openc3/microservices/decom_microservice.rb +7 -4
- data/lib/openc3/microservices/interface_microservice.rb +146 -50
- data/lib/openc3/microservices/log_microservice.rb +1 -3
- data/lib/openc3/microservices/microservice.rb +25 -1
- data/lib/openc3/microservices/plugin_microservice.rb +32 -0
- data/lib/openc3/microservices/text_log_microservice.rb +1 -3
- data/lib/openc3/models/interface_model.rb +1 -1
- data/lib/openc3/models/interface_status_model.rb +6 -0
- data/lib/openc3/models/metric_model.rb +52 -4
- data/lib/openc3/models/microservice_model.rb +7 -3
- data/lib/openc3/models/plugin_model.rb +12 -12
- data/lib/openc3/models/python_package_model.rb +3 -13
- data/lib/openc3/models/target_model.rb +83 -9
- data/lib/openc3/operators/microservice_operator.rb +5 -0
- data/lib/openc3/operators/operator.rb +69 -0
- data/lib/openc3/packets/packet.rb +10 -4
- data/lib/openc3/packets/packet_config.rb +12 -1
- data/lib/openc3/packets/packet_item.rb +27 -14
- data/lib/openc3/packets/parsers/state_parser.rb +1 -1
- data/lib/openc3/packets/structure_item.rb +3 -2
- data/lib/openc3/script/api_shared.rb +58 -84
- data/lib/openc3/script/extract.rb +215 -19
- data/lib/openc3/script/plugins.rb +4 -11
- data/lib/openc3/script/storage.rb +81 -10
- data/lib/openc3/system/system.rb +9 -6
- data/lib/openc3/topics/command_decom_topic.rb +1 -1
- data/lib/openc3/topics/command_topic.rb +2 -1
- data/lib/openc3/topics/router_topic.rb +2 -1
- data/lib/openc3/topics/telemetry_decom_topic.rb +1 -1
- data/lib/openc3/topics/telemetry_topic.rb +3 -2
- data/lib/openc3/topics/topic.rb +154 -2
- data/lib/openc3/utilities/cli_generator.rb +67 -15
- data/lib/openc3/utilities/config_overlay.rb +35 -17
- data/lib/openc3/utilities/default_plugins.rb +33 -0
- data/lib/openc3/utilities/local_mode.rb +82 -46
- data/lib/openc3/utilities/metric.rb +16 -0
- data/lib/openc3/utilities/process_stats.rb +160 -0
- data/lib/openc3/utilities/pypi_url.rb +65 -0
- data/lib/openc3/utilities/questdb_client.rb +14 -0
- data/lib/openc3/utilities/running_script.rb +22 -18
- data/lib/openc3/utilities/store_autoload.rb +20 -3
- data/lib/openc3/utilities/store_queued.rb +37 -9
- data/lib/openc3/utilities/target_file.rb +57 -12
- data/lib/openc3/utilities/topic_lag_monitor.rb +193 -0
- data/lib/openc3/version.rb +6 -6
- data/templates/conversion/conversion.py +6 -2
- data/templates/conversion/conversion.rb +5 -0
- data/templates/plugin/.github/workflows/playwright.yml +62 -0
- data/templates/plugin/.github/workflows/release.yml +45 -0
- data/templates/plugin/.github/workflows/unit-tests.yml +35 -0
- data/templates/plugin/.gitignore +6 -0
- data/templates/plugin/README.md +114 -2
- data/templates/plugin/lib/PACKAGE/__init__.py +4 -0
- data/templates/plugin/plugin.gemspec +10 -1
- data/templates/plugin/pyproject.toml +119 -0
- data/templates/tool_angular/package.json +2 -2
- data/templates/tool_angular/pnpm-workspace.yaml +16 -0
- data/templates/tool_react/package.json +1 -1
- data/templates/tool_react/pnpm-workspace.yaml +16 -0
- data/templates/tool_svelte/package.json +2 -1
- data/templates/tool_svelte/pnpm-workspace.yaml +20 -0
- data/templates/tool_vue/package.json +3 -3
- data/templates/tool_vue/pnpm-workspace.yaml +16 -0
- data/templates/widget/package.json +2 -2
- data/templates/widget/pnpm-workspace.yaml +16 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
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
|
-
|
|
800
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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}#{
|
|
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
|