openc3 7.2.1 → 7.3.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +97 -6
  3. data/bin/pipinstall +5 -4
  4. data/bin/pipuninstall +5 -1
  5. data/bin/uvinstall +149 -0
  6. data/data/config/item_modifiers.yaml +2 -1
  7. data/data/config/microservice.yaml +5 -1
  8. data/data/config/screen.yaml +2 -1
  9. data/data/config/target.yaml +21 -0
  10. data/data/config/tool.yaml +3 -1
  11. data/data/config/widgets.yaml +16 -3
  12. data/data/xtce_schemas/SpaceSystem_20180204.xsd +5918 -0
  13. data/data/xtce_schemas/xml.xsd +116 -0
  14. data/ext/openc3/ext/telemetry/telemetry.c +1 -1
  15. data/lib/openc3/accessors/template_accessor.rb +2 -2
  16. data/lib/openc3/api/interface_api.rb +2 -2
  17. data/lib/openc3/api/limits_api.rb +27 -13
  18. data/lib/openc3/api/router_api.rb +3 -3
  19. data/lib/openc3/api/tlm_api.rb +1 -1
  20. data/lib/openc3/interfaces/http_client_interface.rb +1 -1
  21. data/lib/openc3/io/json_drb.rb +24 -8
  22. data/lib/openc3/io/json_rpc.rb +0 -2
  23. data/lib/openc3/logs/log_writer.rb +43 -7
  24. data/lib/openc3/logs/packet_log_writer.rb +1 -2
  25. data/lib/openc3/microservices/interface_microservice.rb +1 -1
  26. data/lib/openc3/migrations/20260731000000_system_health_json.rb +41 -0
  27. data/lib/openc3/models/cvt_model.rb +5 -5
  28. data/lib/openc3/models/gem_model.rb +1 -1
  29. data/lib/openc3/models/interface_model.rb +1 -1
  30. data/lib/openc3/models/microservice_model.rb +21 -0
  31. data/lib/openc3/models/plugin_model.rb +204 -50
  32. data/lib/openc3/models/python_package_model.rb +207 -11
  33. data/lib/openc3/models/scope_model.rb +34 -30
  34. data/lib/openc3/models/target_model.rb +180 -6
  35. data/lib/openc3/models/tool_model.rb +33 -20
  36. data/lib/openc3/operators/microservice_operator.rb +33 -4
  37. data/lib/openc3/packets/commands.rb +4 -4
  38. data/lib/openc3/packets/limits.rb +15 -7
  39. data/lib/openc3/packets/packet.rb +1 -1
  40. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  41. data/lib/openc3/packets/parsers/xtce_converter.rb +183 -103
  42. data/lib/openc3/packets/parsers/xtce_parser.rb +71 -23
  43. data/lib/openc3/packets/structure.rb +2 -2
  44. data/lib/openc3/packets/telemetry.rb +3 -3
  45. data/lib/openc3/script/commands.rb +1 -1
  46. data/lib/openc3/script/extract.rb +20 -25
  47. data/lib/openc3/script/web_socket_api.rb +44 -4
  48. data/lib/openc3/topics/command_decom_topic.rb +16 -1
  49. data/lib/openc3/topics/limits_event_topic.rb +1 -1
  50. data/lib/openc3/utilities/aws_bucket.rb +10 -2
  51. data/lib/openc3/utilities/csv.rb +5 -5
  52. data/lib/openc3/utilities/local_mode.rb +4 -1
  53. data/lib/openc3/utilities/ruby_lex_utils.rb +5 -1
  54. data/lib/openc3/utilities/running_script.rb +145 -90
  55. data/lib/openc3/utilities/script.rb +46 -10
  56. data/lib/openc3/version.rb +6 -6
  57. data/templates/plugin/plugin.gemspec +1 -1
  58. data/templates/tool_angular/package.json +2 -2
  59. data/templates/tool_react/package.json +1 -1
  60. data/templates/tool_svelte/package.json +1 -1
  61. data/templates/tool_vue/package.json +3 -3
  62. data/templates/widget/package.json +2 -2
  63. metadata +5 -3
  64. data/templates/tool_vue/.nycrc +0 -3
  65. data/templates/widget/.nycrc +0 -3
@@ -0,0 +1,116 @@
1
+ <?xml version='1.0'?>
2
+ <xs:schema targetNamespace="http://www.w3.org/XML/1998/namespace" xmlns:xs="http://www.w3.org/2001/XMLSchema" xml:lang="en">
3
+
4
+ <xs:annotation>
5
+ <xs:documentation>
6
+ See http://www.w3.org/XML/1998/namespace.html and
7
+ http://www.w3.org/TR/REC-xml for information about this namespace.
8
+
9
+ This schema document describes the XML namespace, in a form
10
+ suitable for import by other schema documents.
11
+
12
+ Note that local names in this namespace are intended to be defined
13
+ only by the World Wide Web Consortium or its subgroups. The
14
+ following names are currently defined in this namespace and should
15
+ not be used with conflicting semantics by any Working Group,
16
+ specification, or document instance:
17
+
18
+ base (as an attribute name): denotes an attribute whose value
19
+ provides a URI to be used as the base for interpreting any
20
+ relative URIs in the scope of the element on which it
21
+ appears; its value is inherited. This name is reserved
22
+ by virtue of its definition in the XML Base specification.
23
+
24
+ lang (as an attribute name): denotes an attribute whose value
25
+ is a language code for the natural language of the content of
26
+ any element; its value is inherited. This name is reserved
27
+ by virtue of its definition in the XML specification.
28
+
29
+ space (as an attribute name): denotes an attribute whose
30
+ value is a keyword indicating what whitespace processing
31
+ discipline is intended for the content of the element; its
32
+ value is inherited. This name is reserved by virtue of its
33
+ definition in the XML specification.
34
+
35
+ Father (in any context at all): denotes Jon Bosak, the chair of
36
+ the original XML Working Group. This name is reserved by
37
+ the following decision of the W3C XML Plenary and
38
+ XML Coordination groups:
39
+
40
+ In appreciation for his vision, leadership and dedication
41
+ the W3C XML Plenary on this 10th day of February, 2000
42
+ reserves for Jon Bosak in perpetuity the XML name
43
+ xml:Father
44
+ </xs:documentation>
45
+ </xs:annotation>
46
+
47
+ <xs:annotation>
48
+ <xs:documentation>This schema defines attributes and an attribute group
49
+ suitable for use by
50
+ schemas wishing to allow xml:base, xml:lang or xml:space attributes
51
+ on elements they define.
52
+
53
+ To enable this, such a schema must import this schema
54
+ for the XML namespace, e.g. as follows:
55
+ &lt;schema . . .>
56
+ . . .
57
+ &lt;import namespace="http://www.w3.org/XML/1998/namespace"
58
+ schemaLocation="http://www.w3.org/2001/03/xml.xsd"/>
59
+
60
+ Subsequently, qualified reference to any of the attributes
61
+ or the group defined below will have the desired effect, e.g.
62
+
63
+ &lt;type . . .>
64
+ . . .
65
+ &lt;attributeGroup ref="xml:specialAttrs"/>
66
+
67
+ will define a type which will schema-validate an instance
68
+ element with any of those attributes</xs:documentation>
69
+ </xs:annotation>
70
+
71
+ <xs:annotation>
72
+ <xs:documentation>In keeping with the XML Schema WG's standard versioning
73
+ policy, this schema document will persist at
74
+ http://www.w3.org/2001/03/xml.xsd.
75
+ At the date of issue it can also be found at
76
+ http://www.w3.org/2001/xml.xsd.
77
+ The schema document at that URI may however change in the future,
78
+ in order to remain compatible with the latest version of XML Schema
79
+ itself. In other words, if the XML Schema namespace changes, the version
80
+ of this document at
81
+ http://www.w3.org/2001/xml.xsd will change
82
+ accordingly; the version at
83
+ http://www.w3.org/2001/03/xml.xsd will not change.
84
+ </xs:documentation>
85
+ </xs:annotation>
86
+
87
+ <xs:attribute name="lang" type="xs:language">
88
+ <xs:annotation>
89
+ <xs:documentation>In due course, we should install the relevant ISO 2- and 3-letter
90
+ codes as the enumerated possible values . . .</xs:documentation>
91
+ </xs:annotation>
92
+ </xs:attribute>
93
+
94
+ <xs:attribute name="space" default="preserve">
95
+ <xs:simpleType>
96
+ <xs:restriction base="xs:NCName">
97
+ <xs:enumeration value="default"/>
98
+ <xs:enumeration value="preserve"/>
99
+ </xs:restriction>
100
+ </xs:simpleType>
101
+ </xs:attribute>
102
+
103
+ <xs:attribute name="base" type="xs:anyURI">
104
+ <xs:annotation>
105
+ <xs:documentation>See http://www.w3.org/TR/xmlbase/ for
106
+ information about this attribute.</xs:documentation>
107
+ </xs:annotation>
108
+ </xs:attribute>
109
+
110
+ <xs:attributeGroup name="specialAttrs">
111
+ <xs:attribute ref="xml:base"/>
112
+ <xs:attribute ref="xml:lang"/>
113
+ <xs:attribute ref="xml:space"/>
114
+ </xs:attributeGroup>
115
+
116
+ </xs:schema>
@@ -56,7 +56,7 @@ static VALUE packets(VALUE self, VALUE target_name)
56
56
 
57
57
  if (!(RTEST(target_packets)))
58
58
  {
59
- rb_raise(rb_eRuntimeError, "Telemetry target '%s' does not exist", RSTRING_PTR(upcase_target_name));
59
+ rb_raise(rb_eRuntimeError, "Telemetry target '%s' does not exist (packets lookup)", RSTRING_PTR(upcase_target_name));
60
60
  }
61
61
 
62
62
  return target_packets;
@@ -109,7 +109,7 @@ module OpenC3
109
109
  configure()
110
110
 
111
111
  success = buffer.gsub!("#{@left_char}#{item.key}#{@right_char}", value.to_s)
112
- raise "Key #{item.key} not found in template" unless success
112
+ raise "Key '#{item.key}' not found in template (write_item)" unless success
113
113
  return value
114
114
  end
115
115
 
@@ -118,7 +118,7 @@ module OpenC3
118
118
  items.each_with_index do |item, index|
119
119
  next if item.data_type == :DERIVED
120
120
  success = buffer.gsub!("#{@left_char}#{item.key}#{@right_char}", values[index].to_s)
121
- raise "Key #{item.key} not found in template" unless success
121
+ raise "Key '#{item.key}' not found in template (write_items)" unless success
122
122
  end
123
123
  return values
124
124
  end
@@ -47,7 +47,7 @@ module OpenC3
47
47
  def get_interface(interface_name, manual: false, scope: $openc3_scope, token: $openc3_token)
48
48
  authorize(permission: 'system', interface_name: interface_name, manual: manual, scope: scope, token: token)
49
49
  interface = InterfaceModel.get(name: interface_name, scope: scope)
50
- raise "Interface '#{interface_name}' does not exist" unless interface
50
+ raise "Interface '#{interface_name}' does not exist (get_interface)" unless interface
51
51
 
52
52
  interface.merge(InterfaceStatusModel.get(name: interface_name, scope: scope))
53
53
  end
@@ -160,7 +160,7 @@ module OpenC3
160
160
  # TODO: Check if they have command authority for the targets mapped to this interface
161
161
  authorize(permission: 'system_set', interface_name: interface_name, manual: manual, scope: scope, token: token)
162
162
  interface = InterfaceModel.get_model(name: interface_name, scope: scope)
163
- raise "Interface '#{interface_name}' does not exist" unless interface
163
+ raise "Interface '#{interface_name}' does not exist (unmap_target_from_interface)" unless interface
164
164
 
165
165
  if Array === target_name
166
166
  target_names = target_name
@@ -134,7 +134,7 @@ module OpenC3
134
134
  break
135
135
  end
136
136
  end
137
- raise "Item '#{target_name} #{packet_name} #{item_name}' does not exist" unless found_item
137
+ raise "Item '#{target_name} #{packet_name} #{item_name}' does not exist while enabling limits" unless found_item
138
138
 
139
139
  TargetModel.set_packet(target_name, packet_name, packet, scope: scope)
140
140
 
@@ -167,7 +167,7 @@ module OpenC3
167
167
  break
168
168
  end
169
169
  end
170
- raise "Item '#{target_name} #{packet_name} #{item_name}' does not exist" unless found_item
170
+ raise "Item '#{target_name} #{packet_name} #{item_name}' does not exist while disabling limits" unless found_item
171
171
 
172
172
  TargetModel.set_packet(target_name, packet_name, packet, scope: scope)
173
173
 
@@ -239,7 +239,7 @@ module OpenC3
239
239
  end
240
240
  end
241
241
  end
242
- raise "Item '#{target_name} #{packet_name} #{item_name}' does not exist" unless found_item
242
+ raise "Item '#{target_name} #{packet_name} #{item_name}' does not exist while setting limits" unless found_item
243
243
  message = "Setting '#{target_name} #{packet_name} #{item_name}' limits to #{red_low} #{yellow_low} #{yellow_high} #{red_high}"
244
244
  message << " #{green_low} #{green_high}" if green_low && green_high
245
245
  message << " in set #{limits_set} with persistence #{persistence} as enabled #{enabled}"
@@ -262,14 +262,19 @@ module OpenC3
262
262
  # @param packet_name [String] Packet name
263
263
  # @param item_name [String] Item name
264
264
  # @param state_name [String] Name of the state to change (e.g. 'CONNECTED')
265
- # @param color [String] New color for the state. Must be GREEN, YELLOW, or RED.
265
+ # @param color [String, nil] New color for the state. Must be GREEN, YELLOW,
266
+ # or RED. Pass nil to clear (remove) the state color. Setting a color also
267
+ # enables limits for the item, but clearing the color (nil) does not change
268
+ # whether limits are enabled.
266
269
  def set_state_color(target_name, packet_name, item_name, state_name, color,
267
270
  manual: false, scope: $openc3_scope, token: $openc3_token)
268
271
  authorize(permission: 'tlm_set', target_name: target_name, packet_name: packet_name, manual: manual, scope: scope, token: token)
269
272
  state_name = state_name.to_s.upcase
270
- color = color.to_s.upcase
271
- unless PacketItem::STATE_COLORS.include?(color.intern)
272
- raise "Invalid state color #{color}. Must be one of #{PacketItem::STATE_COLORS.join(' ')}."
273
+ unless color.nil?
274
+ color = color.to_s.upcase
275
+ unless PacketItem::STATE_COLORS.include?(color.intern)
276
+ raise "Invalid state color '#{color}'. Must be one of #{PacketItem::STATE_COLORS.join(', ')}."
277
+ end
273
278
  end
274
279
  packet = TargetModel.packet(target_name, packet_name, scope: scope)
275
280
  found_item = nil
@@ -278,16 +283,25 @@ module OpenC3
278
283
  unless item['states'] && item['states'][state_name]
279
284
  raise "State '#{state_name}' does not exist for item '#{target_name} #{packet_name} #{item_name}'"
280
285
  end
281
- item['states'][state_name]['color'] = color
282
- # Defining a state color implies limits are enabled for the item
283
- item['limits'] ||= {}
284
- item['limits']['enabled'] = true
286
+ if color.nil?
287
+ # Clear the state color
288
+ item['states'][state_name].delete('color')
289
+ else
290
+ item['states'][state_name]['color'] = color
291
+ # Defining a state color implies limits are enabled for the item
292
+ item['limits'] ||= {}
293
+ item['limits']['enabled'] = true
294
+ end
285
295
  found_item = item
286
296
  break
287
297
  end
288
298
  end
289
- raise "Item '#{target_name} #{packet_name} #{item_name}' does not exist" unless found_item
290
- message = "Setting '#{target_name} #{packet_name} #{item_name}' state #{state_name} color to #{color}"
299
+ raise "Item '#{target_name} #{packet_name} #{item_name}' does not exist while setting state color" unless found_item
300
+ if color.nil?
301
+ message = "Clearing '#{target_name} #{packet_name} #{item_name}' state #{state_name} color"
302
+ else
303
+ message = "Setting '#{target_name} #{packet_name} #{item_name}' state #{state_name} color to #{color}"
304
+ end
291
305
  Logger.info(message, scope: scope)
292
306
 
293
307
  TargetModel.set_packet(target_name, packet_name, packet, scope: scope)
@@ -46,7 +46,7 @@ module OpenC3
46
46
  def get_router(router_name, manual: false, scope: $openc3_scope, token: $openc3_token)
47
47
  authorize(permission: 'system', router_name: router_name, manual: manual, scope: scope, token: token)
48
48
  router = RouterModel.get(name: router_name, scope: scope)
49
- raise "Router '#{router_name}' does not exist" unless router
49
+ raise "Router '#{router_name}' does not exist (get_router)" unless router
50
50
 
51
51
  router.merge(RouterStatusModel.get(name: router_name, scope: scope))
52
52
  end
@@ -144,7 +144,7 @@ module OpenC3
144
144
  # TODO: Check if they have command authority for the targets mapped to this router
145
145
  authorize(permission: 'system_set', router_name: router_name, manual: manual, scope: scope, token: token)
146
146
  router = RouterModel.get_model(name: router_name, scope: scope)
147
- raise "Router '#{router_name}' does not exist" unless router
147
+ raise "Router '#{router_name}' does not exist (map_target_to_router)" unless router
148
148
 
149
149
  if Array === target_name
150
150
  target_names = target_name
@@ -167,7 +167,7 @@ module OpenC3
167
167
  # TODO: Check if they have command authority for the targets mapped to this router
168
168
  authorize(permission: 'system_set', router_name: router_name, manual: manual, scope: scope, token: token)
169
169
  router = RouterModel.get_model(name: router_name, scope: scope)
170
- raise "Router '#{router_name}' does not exist" unless router
170
+ raise "Router '#{router_name}' does not exist (unmap_target_from_router)" unless router
171
171
 
172
172
  if Array === target_name
173
173
  target_names = target_name
@@ -123,7 +123,7 @@ module OpenC3
123
123
  target_name = target_name.upcase
124
124
  packet_name = packet_name.upcase
125
125
  unless CvtModel::VALUE_TYPES.include?(type)
126
- raise "Unknown type '#{type}' for #{target_name} #{packet_name}"
126
+ raise "Unknown type '#{type}' for #{target_name} #{packet_name} (inject_tlm)"
127
127
  end
128
128
 
129
129
  if item_hash
@@ -237,7 +237,7 @@ module OpenC3
237
237
  result['read_timeout'] = @read_timeout
238
238
  result['connect_timeout'] = @connect_timeout
239
239
  result['include_request_in_response'] = @include_request_in_response
240
- result['request_queue_length'] = @request_queue.length
240
+ result['response_queue_length'] = @response_queue.length
241
241
  return result
242
242
  end
243
243
  end
@@ -17,6 +17,7 @@
17
17
 
18
18
  require 'socket'
19
19
  require 'json'
20
+ require 'set'
20
21
  # require 'drb/acl'
21
22
  require 'drb/drb'
22
23
  require 'openc3/io/json_rpc'
@@ -47,8 +48,9 @@ module OpenC3
47
48
  # JsonDRb implements the JSON-RPC 2.0 Specification to provide an interface
48
49
  # for both internal and external tools to access the OpenC3 server. It
49
50
  # provides methods to install an access control list to control access to the
50
- # API. It also limits the available methods to a known list of allowable API
51
- # methods.
51
+ # API. Access is controlled by a required method_whitelist which names every
52
+ # method that may be invoked. There is no permissive fallback: any method not
53
+ # explicitly listed is rejected.
52
54
  class JsonDRb
53
55
  # Minimum amount of time in seconds to receive the JSON request,
54
56
  # process it, and send the response. Requests for less than this amount
@@ -62,18 +64,21 @@ module OpenC3
62
64
 
63
65
  # @return [Integer] The number of JSON-RPC requests processed
64
66
  attr_accessor :request_count
65
- # @return [Array<String>] List of methods that should be allowed
66
- attr_accessor :method_whitelist
67
+ # @return [Set<String>] Set of downcased method names which may be called
68
+ attr_reader :method_whitelist
67
69
  # @return [ACL] The access control list
68
70
  # attr_accessor :acl
69
71
 
70
72
  attr_accessor :object
71
73
 
72
- def initialize
74
+ # @param method_whitelist [Enumerable<String>] Every method name which may
75
+ # be called over JSON-RPC. Required and must not be empty since there is
76
+ # no permissive fallback.
77
+ def initialize(method_whitelist:)
78
+ self.method_whitelist = method_whitelist
73
79
  @thread = nil
74
80
  # @acl = nil
75
81
  @object = nil
76
- @method_whitelist = nil
77
82
  @request_count = 0
78
83
  @request_times = []
79
84
  @request_times_index = 0
@@ -82,6 +87,18 @@ module OpenC3
82
87
  @server_mutex = Mutex.new
83
88
  end
84
89
 
90
+ # Replaces the set of methods which may be called over JSON-RPC. Names are
91
+ # downcased to match the dispatch in {#process_request}.
92
+ #
93
+ # @param whitelist [Enumerable<String>] Every method name which may be called
94
+ def method_whitelist=(whitelist)
95
+ if whitelist.nil? or whitelist.to_a.empty?
96
+ raise ArgumentError, "method_whitelist is required and must not be empty"
97
+ end
98
+
99
+ @method_whitelist = whitelist.map { |name| name.to_s.downcase }.to_set
100
+ end
101
+
85
102
  # Returns the number of connected clients
86
103
  # @return [Integer] The number of connected clients
87
104
  def num_clients
@@ -254,8 +271,7 @@ module OpenC3
254
271
  error_code = nil
255
272
  response_data = nil
256
273
 
257
- if (@method_whitelist and @method_whitelist.include?(request.method.downcase())) or
258
- (!@method_whitelist and !JsonRpcRequest::DANGEROUS_METHODS.include?(request.method.downcase()))
274
+ if @method_whitelist.include?(request.method.downcase())
259
275
  begin
260
276
  if request.keyword_params
261
277
  result = @object.public_send(request.method.downcase().intern, *request.params, **request.keyword_params)
@@ -227,8 +227,6 @@ module OpenC3
227
227
 
228
228
  # Represents a JSON Remote Procedure Call Request
229
229
  class JsonRpcRequest < JsonRpc
230
- DANGEROUS_METHODS = Set['__send__', 'send', 'instance_eval', 'instance_exec']
231
-
232
230
  # @param method_name [String] The name of the method to call
233
231
  # @param method_params [Array<String>] Array of strings which represent the
234
232
  # parameters to send to the method
@@ -113,6 +113,7 @@ module OpenC3
113
113
  @cycle_minute = Integer(@cycle_minute) if @cycle_minute
114
114
  @enforce_time_order = ConfigParser.handle_true_false(enforce_time_order)
115
115
  @out_of_order = false
116
+ @bad_time = false
116
117
  @mutex = Mutex.new
117
118
  @file = nil
118
119
  @file_size = 0
@@ -125,6 +126,13 @@ module OpenC3
125
126
  @cleanup_offsets = []
126
127
  @cleanup_times = []
127
128
  @previous_time_nsec_since_epoch = nil
129
+ # Stored packets older than 20 years are treated as invalid (e.g. 1970 before
130
+ # clock/GPS sync). They never drive file segmentation nor become the ordering
131
+ # baseline, so a late-syncing clock doesn't thrash log files.
132
+ @min_valid_time_nsec = (@start_time - (20 * 365 * 24 * 60 * 60)).to_nsec_from_epoch
133
+ # Validity (valid/invalid time) of the packets in the currently open file. Used to
134
+ # segregate invalid-time (1970) packets into their own file. nil = no packets yet.
135
+ @current_file_valid = nil
128
136
  @tmp_dir = Dir.mktmpdir
129
137
  @wait_threads = []
130
138
 
@@ -280,6 +288,7 @@ module OpenC3
280
288
  @first_time = nil
281
289
  @last_time = nil
282
290
  @previous_time_nsec_since_epoch = nil
291
+ @current_file_valid = nil
283
292
  Logger.debug "Log File Opened : #{@filename}"
284
293
  rescue => e
285
294
  Logger.error "Error starting new log file: #{e.formatted}"
@@ -289,7 +298,14 @@ module OpenC3
289
298
 
290
299
  # @enforce_time_order requires the timestamps on each write to be greater than the previous
291
300
  # process_out_of_order ignores the timestamps for the current entry (used to ignore timestamps on metadata entries, vs actual packets)
292
- def prepare_write(time_nsec_since_epoch, data_length, redis_topic = nil, redis_offset = nil, allow_new_file: true, process_out_of_order: true)
301
+ # stored: stored packets roll a new file when GOOD times move backward (a new /
302
+ # overlapping replay run). Packets with an invalid time (e.g. 1970 before clock/GPS
303
+ # sync) - realtime or stored - are segregated into their own file: the first valid
304
+ # packet after a run of 1970s rolls a fresh, properly-named file (and vice-versa).
305
+ def prepare_write(time_nsec_since_epoch, data_length, redis_topic = nil, redis_offset = nil, allow_new_file: true, process_out_of_order: true, stored: false)
306
+ # An invalid time is a packet from before the clock/GPS synced (e.g. 1970). This
307
+ # applies to both realtime and stored packets so neither taints a file's filename.
308
+ packet_valid = !(process_out_of_order and time_nsec_since_epoch < @min_valid_time_nsec)
293
309
  # This check includes logging_enabled again because it might have changed since we acquired the mutex
294
310
  # Ensures new files based on size, and ensures always increasing time order in files
295
311
  if @logging_enabled
@@ -299,17 +315,37 @@ module OpenC3
299
315
  elsif @cycle_size and ((@file_size + data_length) > @cycle_size)
300
316
  Logger.debug("Log writer start new file due to cycle size #{@cycle_size}")
301
317
  start_new_file() if allow_new_file
318
+ elsif process_out_of_order and !@current_file_valid.nil? and packet_valid != @current_file_valid and allow_new_file
319
+ # Time validity changed (1970 <-> valid): segregate invalid-time packets into
320
+ # their own file so valid files keep a clean, correctly-timed filename.
321
+ Logger.debug("Log writer start new file due to time validity change")
322
+ start_new_file()
302
323
  elsif process_out_of_order and @enforce_time_order and @previous_time_nsec_since_epoch and (@previous_time_nsec_since_epoch > time_nsec_since_epoch)
303
- # Warning: Creating new files here can cause lots of files to be created if packets make it through out of order
304
- # Changed to just a error to prevent file thrashing
305
- unless @out_of_order
306
- Logger.error("Log writer out of order time detected (increase buffer depth?): #{Time.from_nsec_from_epoch(@previous_time_nsec_since_epoch)} #{Time.from_nsec_from_epoch(time_nsec_since_epoch)}")
307
- @out_of_order = true
324
+ if stored and packet_valid and allow_new_file
325
+ # Good stored time went backward => new/overlapping replay run. Roll a new
326
+ # file so each stored file stays monotonic (start_new_file resets @previous_time).
327
+ Logger.debug("Log writer start new file due to out of order stored time")
328
+ start_new_file()
329
+ elsif !stored
330
+ # Warning: Creating new files here can cause lots of files to be created if packets make it through out of order
331
+ # Changed to just a warning to prevent file thrashing
332
+ unless @out_of_order
333
+ Logger.warn("Log writer out of order time detected (increase buffer depth?): #{Time.from_nsec_from_epoch(@previous_time_nsec_since_epoch)} #{Time.from_nsec_from_epoch(time_nsec_since_epoch)}")
334
+ @out_of_order = true
335
+ end
308
336
  end
309
337
  end
310
338
  end
311
339
  @last_offsets[redis_topic] = redis_offset if redis_topic and redis_offset # This is needed for the redis offset marker entry at the end of the log file
312
- @previous_time_nsec_since_epoch = time_nsec_since_epoch if process_out_of_order
340
+ if process_out_of_order
341
+ @current_file_valid = packet_valid
342
+ if packet_valid
343
+ @previous_time_nsec_since_epoch = time_nsec_since_epoch
344
+ elsif !@bad_time
345
+ Logger.warn("Log writer segregating invalid packet time (before clock sync?): #{Time.from_nsec_from_epoch(time_nsec_since_epoch)}")
346
+ @bad_time = true
347
+ end
348
+ end
313
349
  end
314
350
 
315
351
  # Closing a log file isn't critical so we just log an error. NOTE: This also trims the Redis stream
@@ -96,13 +96,12 @@ module OpenC3
96
96
  @mutex.lock if take_mutex
97
97
  begin
98
98
  if entry_type == :RAW_PACKET or entry_type == :JSON_PACKET
99
- # Only care about the timestamps on the real packets being in order
100
99
  process_out_of_order = true
101
100
  else
102
101
  # Metadata timestamps don't matter
103
102
  process_out_of_order = false
104
103
  end
105
- prepare_write(time_nsec_since_epoch, data.length, redis_topic, redis_offset, allow_new_file: allow_new_file, process_out_of_order: process_out_of_order)
104
+ prepare_write(time_nsec_since_epoch, data.length, redis_topic, redis_offset, allow_new_file: allow_new_file, process_out_of_order: process_out_of_order, stored: stored)
106
105
  write_entry(entry_type, cmd_or_tlm, target_name, packet_name, time_nsec_since_epoch, stored, data, id, received_time_nsec_since_epoch: received_time_nsec_since_epoch, extra: extra) if @file
107
106
  ensure
108
107
  @mutex.unlock if take_mutex
@@ -195,7 +195,7 @@ module OpenC3
195
195
  msg_hash = critical_model.cmd_hash
196
196
  release_critical = true
197
197
  else
198
- next "Critical command #{msg_hash['release_critical']} not found"
198
+ next "Critical command '#{msg_hash['release_critical']}' not found (interface command processing)"
199
199
  end
200
200
  end
201
201
  if msg_hash.key?('target_control')
@@ -0,0 +1,41 @@
1
+ require 'json'
2
+ require 'openc3/utilities/local_mode'
3
+ require 'openc3/utilities/migration'
4
+ require 'openc3/models/scope_model'
5
+ require 'openc3/models/setting_model'
6
+
7
+ module OpenC3
8
+ # The system_health setting was originally stored as a Ruby Hash. Local mode
9
+ # writes settings to disk with File.write and reads them back as a String, so
10
+ # the Hash round tripped into a Ruby inspect String which nothing can parse.
11
+ # Store it as a JSON string like every other setting and repair existing data.
12
+ class SystemHealthJson < Migration
13
+ def self.run
14
+ setting = SettingModel.get(name: 'system_health')
15
+ data = repair(setting && setting['data'])
16
+ SettingModel.set({ name: 'system_health', data: JSON.generate(data) }, scope: 'DEFAULT')
17
+ # Repair the local mode file as well or sync_settings puts the bad data back
18
+ LocalMode.save_setting('DEFAULT', 'system_health', JSON.generate(data))
19
+ end
20
+
21
+ # @return [Hash] the existing settings if they can be recovered, otherwise defaults
22
+ def self.repair(data)
23
+ return ScopeModel::SYSTEM_HEALTH_DEFAULTS if data.nil?
24
+ return data if Hash === data
25
+ return ScopeModel::SYSTEM_HEALTH_DEFAULTS unless String === data
26
+ begin
27
+ parsed = JSON.parse(data)
28
+ rescue JSON::ParserError
29
+ # Ruby inspect format, e.g. {"cpu" => {"redThreshold" => 90, ... => nil}}
30
+ # Ruby 3.3 and earlier had no spaces around the rocket. All the values are
31
+ # numbers, booleans, or nil so there's nothing to accidentally rewrite.
32
+ parsed = JSON.parse(data.gsub(/\s*=>\s*/, ': ').gsub(/\bnil\b/, 'null')) rescue nil
33
+ end
34
+ Hash === parsed ? parsed : ScopeModel::SYSTEM_HEALTH_DEFAULTS
35
+ end
36
+ end
37
+ end
38
+
39
+ unless ENV['OPENC3_NO_MIGRATE']
40
+ OpenC3::SystemHealthJson.run
41
+ end
@@ -86,7 +86,7 @@ module OpenC3
86
86
  return hash if hash and (now - cache_time) < cache_timeout
87
87
  end
88
88
  packet = store_for_target(target_name, scope: scope).hget(key, packet_name)
89
- raise "Packet '#{target_name} #{packet_name}' does not exist" unless packet
89
+ raise "Packet '#{target_name} #{packet_name}' has no current values in CVT" unless packet
90
90
  hash = JSON.parse(packet, allow_nan: true, create_additions: true)
91
91
  @@packet_cache[tgt_pkt_key] = [now, hash]
92
92
  hash
@@ -107,7 +107,7 @@ module OpenC3
107
107
  hash["#{item_name}__C"] = value
108
108
  hash[item_name] = value
109
109
  else
110
- raise "Unknown type '#{type}' for #{target_name} #{packet_name} #{item_name}"
110
+ raise "Unknown type '#{type}' for #{target_name} #{packet_name} #{item_name} (set_item)"
111
111
  end
112
112
  set(hash, target_name: target_name, packet_name: packet_name, queued: queued, scope: scope)
113
113
  end
@@ -252,7 +252,7 @@ module OpenC3
252
252
  when :FORMATTED, :WITH_UNITS
253
253
  hash["#{item_name}__F"] = value.to_s # Always a String
254
254
  else
255
- raise "Unknown type '#{type}' for #{target_name} #{packet_name} #{item_name}"
255
+ raise "Unknown type '#{type}' for #{target_name} #{packet_name} #{item_name} (override)"
256
256
  end
257
257
 
258
258
  tgt_pkt_key = "#{scope}__tlm__#{target_name}__#{packet_name}"
@@ -277,7 +277,7 @@ module OpenC3
277
277
  when :FORMATTED, :WITH_UNITS
278
278
  hash.delete("#{item_name}__F")
279
279
  else
280
- raise "Unknown type '#{type}' for #{target_name} #{packet_name} #{item_name}"
280
+ raise "Unknown type '#{type}' for #{target_name} #{packet_name} #{item_name} (normalize)"
281
281
  end
282
282
 
283
283
  tgt_pkt_key = "#{scope}__tlm__#{target_name}__#{packet_name}"
@@ -327,7 +327,7 @@ module OpenC3
327
327
  when :RAW
328
328
  types = [item_name]
329
329
  else
330
- raise "Unknown type '#{type}' for #{target_name} #{packet_name} #{item_name}"
330
+ raise "Unknown type '#{type}' for #{target_name} #{packet_name} #{item_name} (get_item)"
331
331
  end
332
332
 
333
333
  tgt_pkt_key = "#{scope}__tlm__#{target_name}__#{packet_name}"
@@ -47,7 +47,7 @@ module OpenC3
47
47
  return path if File.exist?(path)
48
48
  path = "#{ENV['GEM_HOME']}/cache/#{name}"
49
49
  return path if File.exist?(path)
50
- raise "Gem #{name} not found"
50
+ raise "Gem '#{name}' not found"
51
51
  end
52
52
 
53
53
  def self.put(gem_file_path, gem_install: true, scope:)
@@ -256,7 +256,7 @@ module OpenC3
256
256
 
257
257
  def ensure_target_exists(target_name)
258
258
  target = TargetModel.get(name: target_name, scope: @scope)
259
- raise "Target #{target_name} does not exist" unless target
259
+ raise "Target '#{target_name}' does not exist (InterfaceModel)" unless target
260
260
  target
261
261
  end
262
262
 
@@ -140,6 +140,27 @@ module OpenC3
140
140
  @bucket = Bucket.getClient()
141
141
  end
142
142
 
143
+ # Resolve the Python environment variables that will be used at runtime.
144
+ # This mirrors the logic in MicroserviceOperator#convert_microservice_to_process_definition.
145
+ def runtime_python_env
146
+ python_bin = ENV['OPENC3_PYTHON_BIN'] || '/openc3/python/.venv/bin/python'
147
+ result = { 'OPENC3_PYTHON_BIN' => python_bin }
148
+ if @needs_dependencies && @plugin
149
+ sanitized_name = "#{@scope}__#{@plugin}".tr('^a-zA-Z0-9_-', '_')
150
+ candidate = "/gems/plugin_venvs/#{sanitized_name}/.venv"
151
+ if File.directory?(candidate)
152
+ result['VIRTUAL_ENV'] = candidate
153
+ result['PYTHONUSERBASE'] = candidate
154
+ site_packages = Dir.glob("#{candidate}/lib/python*/site-packages").first
155
+ result['PYTHONPATH'] = site_packages
156
+ else
157
+ result['VIRTUAL_ENV'] = nil
158
+ result['PYTHONUSERBASE'] = '/gems/python_packages'
159
+ end
160
+ end
161
+ result
162
+ end
163
+
143
164
  def as_json(*a)
144
165
  {
145
166
  'name' => @name,