openc3 7.2.0 → 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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +108 -29
  3. data/bin/pipinstall +5 -4
  4. data/bin/pipuninstall +5 -1
  5. data/bin/uvinstall +149 -0
  6. data/data/config/interface_modifiers.yaml +15 -0
  7. data/data/config/item_modifiers.yaml +2 -1
  8. data/data/config/microservice.yaml +5 -1
  9. data/data/config/screen.yaml +2 -1
  10. data/data/config/target.yaml +38 -0
  11. data/data/config/tool.yaml +3 -1
  12. data/data/config/widgets.yaml +43 -3
  13. data/data/xtce_schemas/SpaceSystem_20180204.xsd +5918 -0
  14. data/data/xtce_schemas/xml.xsd +116 -0
  15. data/ext/openc3/ext/telemetry/telemetry.c +1 -1
  16. data/lib/openc3/accessors/binary_accessor.rb +4 -0
  17. data/lib/openc3/accessors/template_accessor.rb +2 -2
  18. data/lib/openc3/api/cmd_api.rb +5 -1
  19. data/lib/openc3/api/interface_api.rb +2 -2
  20. data/lib/openc3/api/limits_api.rb +89 -3
  21. data/lib/openc3/api/router_api.rb +3 -3
  22. data/lib/openc3/api/tlm_api.rb +1 -1
  23. data/lib/openc3/interfaces/http_client_interface.rb +1 -1
  24. data/lib/openc3/io/json_drb.rb +24 -8
  25. data/lib/openc3/io/json_rpc.rb +0 -2
  26. data/lib/openc3/logs/log_writer.rb +43 -7
  27. data/lib/openc3/logs/packet_log_writer.rb +1 -2
  28. data/lib/openc3/microservices/decom_common.rb +15 -4
  29. data/lib/openc3/microservices/decom_microservice.rb +6 -1
  30. data/lib/openc3/microservices/interface_microservice.rb +26 -12
  31. data/lib/openc3/microservices/queue_microservice.rb +4 -2
  32. data/lib/openc3/migrations/20260701000000_updated_at_to_int.rb +55 -0
  33. data/lib/openc3/migrations/20260731000000_system_health_json.rb +41 -0
  34. data/lib/openc3/models/cvt_model.rb +7 -7
  35. data/lib/openc3/models/db_sharded_model.rb +2 -2
  36. data/lib/openc3/models/gem_model.rb +1 -1
  37. data/lib/openc3/models/interface_model.rb +17 -11
  38. data/lib/openc3/models/interface_status_model.rb +2 -2
  39. data/lib/openc3/models/metric_model.rb +2 -2
  40. data/lib/openc3/models/microservice_model.rb +21 -0
  41. data/lib/openc3/models/microservice_status_model.rb +2 -2
  42. data/lib/openc3/models/model.rb +2 -6
  43. data/lib/openc3/models/plugin_model.rb +204 -49
  44. data/lib/openc3/models/python_package_model.rb +210 -12
  45. data/lib/openc3/models/reingest_job_model.rb +1 -1
  46. data/lib/openc3/models/scope_model.rb +34 -30
  47. data/lib/openc3/models/script_status_model.rb +73 -38
  48. data/lib/openc3/models/target_model.rb +193 -6
  49. data/lib/openc3/models/tool_model.rb +33 -20
  50. data/lib/openc3/operators/microservice_operator.rb +33 -4
  51. data/lib/openc3/operators/operator.rb +15 -5
  52. data/lib/openc3/packets/commands.rb +7 -7
  53. data/lib/openc3/packets/limits.rb +33 -2
  54. data/lib/openc3/packets/packet.rb +6 -4
  55. data/lib/openc3/packets/packet_config.rb +5 -1
  56. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  57. data/lib/openc3/packets/parsers/xtce_converter.rb +183 -103
  58. data/lib/openc3/packets/parsers/xtce_parser.rb +71 -23
  59. data/lib/openc3/packets/structure.rb +16 -12
  60. data/lib/openc3/packets/telemetry.rb +3 -3
  61. data/lib/openc3/script/commands.rb +1 -1
  62. data/lib/openc3/script/extract.rb +20 -25
  63. data/lib/openc3/script/limits.rb +1 -1
  64. data/lib/openc3/script/suite_runner.rb +43 -9
  65. data/lib/openc3/script/web_socket_api.rb +45 -5
  66. data/lib/openc3/system/target.rb +4 -1
  67. data/lib/openc3/tools/table_manager/table_config.rb +4 -1
  68. data/lib/openc3/top_level.rb +1 -1
  69. data/lib/openc3/topics/command_decom_topic.rb +16 -1
  70. data/lib/openc3/topics/limits_event_topic.rb +63 -4
  71. data/lib/openc3/topics/telemetry_decom_topic.rb +2 -2
  72. data/lib/openc3/utilities/aws_bucket.rb +10 -2
  73. data/lib/openc3/utilities/cli_generator.rb +5 -1
  74. data/lib/openc3/utilities/csv.rb +5 -5
  75. data/lib/openc3/utilities/env_helper.rb +13 -1
  76. data/lib/openc3/utilities/local_mode.rb +4 -1
  77. data/lib/openc3/utilities/pypi_url.rb +39 -0
  78. data/lib/openc3/utilities/reingest_job.rb +1 -1
  79. data/lib/openc3/utilities/ruby_lex_utils.rb +5 -1
  80. data/lib/openc3/utilities/running_script.rb +160 -100
  81. data/lib/openc3/utilities/script.rb +60 -13
  82. data/lib/openc3/utilities/store_autoload.rb +39 -1
  83. data/lib/openc3/utilities/store_queued.rb +5 -1
  84. data/lib/openc3/version.rb +5 -5
  85. data/lib/openc3.rb +6 -1
  86. data/templates/plugin/plugin.gemspec +1 -1
  87. data/templates/tool_angular/package.json +2 -2
  88. data/templates/tool_react/package.json +1 -1
  89. data/templates/tool_svelte/package.json +1 -1
  90. data/templates/tool_vue/package.json +3 -3
  91. data/templates/widget/package.json +2 -2
  92. metadata +7 -3
  93. data/templates/tool_vue/.nycrc +0 -3
  94. data/templates/widget/.nycrc +0 -3
@@ -326,7 +326,7 @@ module OpenC3
326
326
  # @return [StructureItem] StructureItem or one of its subclasses
327
327
  def get_item(name)
328
328
  item = @items[name.upcase]
329
- raise ArgumentError, "Unknown item: #{name}" unless item
329
+ raise ArgumentError, "Unknown item: #{name} (get_item)" unless item
330
330
 
331
331
  return item
332
332
  end
@@ -358,7 +358,7 @@ module OpenC3
358
358
  # @param name [String] Name of the item to delete in the items Hash
359
359
  def delete_item(name)
360
360
  item = @items[name.upcase]
361
- raise ArgumentError, "Unknown item: #{name}" unless item
361
+ raise ArgumentError, "Unknown item: #{name} (delete_item)" unless item
362
362
 
363
363
  # Find the item to delete in the sorted_items array
364
364
  item_index = nil
@@ -604,19 +604,23 @@ module OpenC3
604
604
  if item.variable_bit_size
605
605
  # Bit size is determined by length field
606
606
  length_value = self.read(item.variable_bit_size['length_item_name'], :CONVERTED)
607
- if (item.data_type == :INT or item.data_type == :UINT) and not item.original_array_size
608
- case length_value
609
- when 0
610
- return 6
611
- when 1
612
- return 14
613
- when 2
614
- return 30
607
+ if length_value
608
+ if (item.data_type == :INT or item.data_type == :UINT) and not item.original_array_size
609
+ case length_value
610
+ when 0
611
+ return 6
612
+ when 1
613
+ return 14
614
+ when 2
615
+ return 30
616
+ else
617
+ return 62
618
+ end
615
619
  else
616
- return 62
620
+ return (length_value * item.variable_bit_size['length_bits_per_count']) + item.variable_bit_size['length_value_bit_offset']
617
621
  end
618
622
  else
619
- return (length_value * item.variable_bit_size['length_bits_per_count']) + item.variable_bit_size['length_value_bit_offset']
623
+ raise "Length value #{item.variable_bit_size['length_item_name']} for item #{item.name} is nil"
620
624
  end
621
625
  elsif item.original_bit_size <= 0
622
626
  # Bit size is full packet length - bits before item + negative bits saved at end
@@ -59,7 +59,7 @@ module OpenC3
59
59
  def packets(target_name)
60
60
  upcase_target_name = target_name.to_s.upcase
61
61
  target_packets = @config.telemetry[upcase_target_name]
62
- raise "Telemetry target '#{upcase_target_name}' does not exist" unless target_packets
62
+ raise "Telemetry target '#{upcase_target_name}' does not exist (packets lookup)" unless target_packets
63
63
 
64
64
  target_packets
65
65
  end
@@ -177,7 +177,7 @@ module OpenC3
177
177
  if LATEST_PACKET_NAME.casecmp(packet_name).zero?
178
178
  target_upcase = target_name.to_s.upcase
179
179
  target_latest_data = @config.latest_data[target_upcase]
180
- raise "Telemetry Target '#{target_upcase}' does not exist" unless target_latest_data
180
+ raise "Telemetry target '#{target_upcase}' does not exist (item_names lookup)" unless target_latest_data
181
181
 
182
182
  item_names = target_latest_data.keys
183
183
  else
@@ -208,7 +208,7 @@ module OpenC3
208
208
  target_upcase = target_name.to_s.upcase
209
209
  item_upcase = item_name.to_s.upcase
210
210
  target_latest_data = @config.latest_data[target_upcase]
211
- raise "Telemetry target '#{target_upcase}' does not exist" unless target_latest_data
211
+ raise "Telemetry target '#{target_upcase}' does not exist (latest_packets lookup)" unless target_latest_data
212
212
 
213
213
  packets = @config.latest_data[target_upcase][item_upcase]
214
214
  raise "Telemetry item '#{target_upcase} #{LATEST_PACKET_NAME} #{item_upcase}' does not exist" unless packets
@@ -106,7 +106,7 @@ module OpenC3
106
106
  cmd_params.each do |param_name, _param_value|
107
107
  param = command['items'].find { |item| item['name'] == param_name }
108
108
  unless param
109
- raise "Packet item '#{target_name} #{cmd_name} #{param_name}' does not exist"
109
+ raise "Item '#{target_name} #{cmd_name} #{param_name}' does not exist (command parameter validation)"
110
110
  end
111
111
  end
112
112
  _log_cmd(command, raw, no_range, no_hazardous)
@@ -20,7 +20,11 @@ require 'openc3/utilities/store'
20
20
 
21
21
  module OpenC3
22
22
  module Extract
23
- SCANNING_REGULAR_EXPRESSION = %r{ (?:"(?:[^\\"]|\\.)*") | (?:'(?:[^\\']|\\.)*') | (?:\[(?:[^\\\[\]]|\\.)*\]) | \S+ }x # "
23
+ # Tokenizer for command parameters: matches double quoted strings, single quoted strings,
24
+ # bracket delimited arrays (one level of nesting), a bare comma delimiter, or bare words
25
+ # (runs of non-whitespace, non-comma characters). Commas are tokenized separately so
26
+ # whitespace around them is optional.
27
+ SCANNING_REGULAR_EXPRESSION = %r{ (?:"(?:[^\\"]|\\.)*") | (?:'(?:[^\\']|\\.)*') | (?:\[(?:[^\\\[\]]|\\.|\[(?:[^\\\[\]]|\\.)*\])*\]) | , | [^\s,]+ }x # "
24
28
 
25
29
  private
26
30
 
@@ -88,35 +92,26 @@ module OpenC3
88
92
  second_half = split_string[1].scan(SCANNING_REGULAR_EXPRESSION)
89
93
  keyword = nil
90
94
  value = nil
91
- comma = nil
92
95
  second_half.each do |item|
93
- unless keyword
96
+ if item == ','
97
+ # A comma completes the current keyword / value pair.
98
+ # A comma with nothing pending is a leading or duplicated comma.
99
+ raise "Missing command parameter before comma: #{text}" unless keyword
100
+ raise "Missing value for last command parameter: #{text}" unless value
101
+ add_cmd_parameter(keyword, value, packet, cmd_params)
102
+ keyword = nil
103
+ value = nil
104
+ elsif keyword.nil?
94
105
  keyword = item
95
- next
96
- end
97
- unless value
98
- if item[-1..-1] == ','
99
- value = item[0..-2]
100
- comma = true
101
- else
102
- value = item
103
- next
104
- end
105
- end
106
- unless comma
107
- raise "Missing comma in command parameters: #{text}" if item != ','
106
+ elsif value.nil?
107
+ value = item
108
+ else
109
+ raise "Missing comma in command parameters: #{text}"
108
110
  end
109
- add_cmd_parameter(keyword, value, packet, cmd_params)
110
- keyword = nil
111
- value = nil
112
- comma = nil
113
111
  end
114
112
  if keyword
115
- if value
116
- add_cmd_parameter(keyword, value, packet, cmd_params)
117
- else
118
- raise "Missing value for last command parameter: #{text}"
119
- end
113
+ raise "Missing value for last command parameter: #{text}" unless value
114
+ add_cmd_parameter(keyword, value, packet, cmd_params)
120
115
  end
121
116
  end
122
117
 
@@ -20,7 +20,7 @@ module OpenC3
20
20
  private
21
21
 
22
22
  # Define all the modification methods such that we can disconnect them
23
- %i(enable_limits disable_limits set_limits enable_limits_group disable_limits_group set_limits_set).each do |method_name|
23
+ %i(enable_limits disable_limits set_limits set_state_color enable_limits_group disable_limits_group set_limits_set delete_limits_set).each do |method_name|
24
24
  define_method(method_name) do |*args, **kw_args, &block|
25
25
  kw_args[:scope] = $openc3_scope unless kw_args[:scope]
26
26
  if $disconnect
@@ -26,6 +26,11 @@ module OpenC3
26
26
  end
27
27
 
28
28
  class SuiteRunner
29
+ # Default suite_runner options shared by all callers (e.g. openc3cli) so
30
+ # the values stay consistent in one place.
31
+ DEFAULT_METHOD = 'start'
32
+ DEFAULT_OPTIONS = ['continueAfterError'].freeze
33
+
29
34
  @@suites = []
30
35
  @@settings = {}
31
36
  @@suite_results = nil
@@ -42,17 +47,46 @@ module OpenC3
42
47
  @@suite_results
43
48
  end
44
49
 
50
+ # Validate the suite_runner option combinations that don't require the
51
+ # built suites. Shared by all callers (the running script via execute and
52
+ # openc3cli when constructing options) so the contract lives in one place.
53
+ # Suite/Group existence is checked in execute since it needs @@suites.
54
+ def self.validate_options(group: nil, script: nil)
55
+ raise ArgumentError, "Script #{script} requires a Group" if script and not group
56
+ end
57
+
58
+ # Build the canonical, validated suite_runner hash from raw values,
59
+ # applying defaults. Shared by openc3cli (constructing from CLI flags) and
60
+ # the running script (normalizing a received hash before dispatch) so the
61
+ # hash shape, defaults, and validation live in one place.
62
+ def self.build_options(suite:, group: nil, script: nil, method: nil, options: nil)
63
+ validate_options(group: group, script: script)
64
+ suite_runner = { 'suite' => suite }
65
+ if group
66
+ suite_runner['group'] = group
67
+ suite_runner['script'] = script if script
68
+ end
69
+ # A script always runs via start, matching the GUI
70
+ suite_runner['method'] = script ? DEFAULT_METHOD : (method || DEFAULT_METHOD)
71
+ suite_runner['options'] = options || DEFAULT_OPTIONS.dup
72
+ suite_runner
73
+ end
74
+
45
75
  def self.execute(result_string, suite_class, group_class = nil, script = nil)
46
76
  @@suite_results = SuiteResults.new
47
- @@suites.each do |suite|
48
- if suite.class == suite_class
49
- @@suite_results.start(result_string, suite_class, group_class, script, @@settings)
50
- loop do
51
- yield(suite)
52
- break if not @@settings['Loop'] or (ScriptStatus.instance.fail_count > 0 and @@settings['Break Loop On Error'])
53
- end
54
- break
55
- end
77
+ # Surface invalid suite / group / option combinations rather than
78
+ # silently running nothing or failing with an opaque nil error. An
79
+ # invalid script method raises later in Group#run_method.
80
+ validate_options(group: group_class, script: script)
81
+ suite = @@suites.find { |s| s.class == suite_class }
82
+ raise ArgumentError, "Suite #{suite_class} not found" unless suite
83
+ if group_class and not suite.scripts.key?(group_class)
84
+ raise ArgumentError, "Group #{group_class} not found in Suite #{suite_class}"
85
+ end
86
+ @@suite_results.start(result_string, suite_class, group_class, script, @@settings)
87
+ loop do
88
+ yield(suite)
89
+ break if not @@settings['Loop'] or (ScriptStatus.instance.fail_count > 0 and @@settings['Break Loop On Error'])
56
90
  end
57
91
  end
58
92
 
@@ -1,4 +1,4 @@
1
- # encoding: ascii-8bit
1
+ # encoding: utf-8
2
2
 
3
3
  # Copyright 2026 OpenC3, Inc.
4
4
  # All Rights Reserved.
@@ -22,7 +22,10 @@ module OpenC3
22
22
 
23
23
  # Create the WebsocketApi object. If a block is given will automatically connect/disconnect
24
24
  def initialize(url:, write_timeout: 10.0, read_timeout: 10.0, connect_timeout: 5.0, authentication: nil, scope: $openc3_scope, &block)
25
- @scope = scope
25
+ # $openc3_scope is only set inside the Script Runner / microservice
26
+ # environment. Fall back to OPENC3_SCOPE (mirrors the Python client) so a
27
+ # bare `ruby script.rb` doesn't send a nil scope that the server rejects.
28
+ @scope = scope || ENV.fetch('OPENC3_SCOPE', 'DEFAULT')
26
29
  @authentication = authentication.nil? ? generate_auth() : authentication
27
30
  @url = url
28
31
  @write_timeout = write_timeout
@@ -100,6 +103,34 @@ module OpenC3
100
103
  json_hash['identifier'] = JSON.generate(@identifier, allow_nan: true)
101
104
  @stream.write(JSON.generate(json_hash, allow_nan: true))
102
105
  @subscribed = true
106
+ wait_for_subscribed()
107
+ end
108
+ end
109
+
110
+ # Block until the server confirms the subscription. ActionCable / anycable-go
111
+ # process 'subscribe' and 'message' commands as independent RPCs, so an action
112
+ # (add/remove) written immediately after subscribe can reach
113
+ # StreamingChannel#add before the subscription's broadcaster exists, where it
114
+ # is silently dropped (a no-op) and no data ever streams. Waiting for
115
+ # confirm_subscription guarantees the broadcaster is ready before any action
116
+ # is written.
117
+ def wait_for_subscribed
118
+ while true
119
+ message = @stream.read
120
+ raise "WebSocket closed before subscription was confirmed" if message.nil? || message.empty?
121
+
122
+ json_hash = JSON.parse(message, allow_nan: true, create_additions: true)
123
+ case json_hash['type']
124
+ when 'confirm_subscription'
125
+ return
126
+ when 'reject_subscription'
127
+ raise "Subscription Rejected"
128
+ when 'disconnect'
129
+ raise "Unauthorized" if json_hash['reason'] == 'unauthorized'
130
+ else
131
+ # Ignore welcome / ping and keep waiting for confirmation
132
+ next
133
+ end
103
134
  end
104
135
  end
105
136
 
@@ -116,6 +147,13 @@ module OpenC3
116
147
 
117
148
  # Send an ActionCable command
118
149
  def write_action(data_hash)
150
+ # Subscribe first so the token is present in @identifier before we
151
+ # serialize it below. ActionCable matches a 'message' command to its
152
+ # subscription by the exact identifier string; if subscribe() injected the
153
+ # token only afterward, the message identifier (no token) would not match
154
+ # the subscription identifier (with token) and the server would silently
155
+ # ignore the action.
156
+ subscribe()
119
157
  json_hash = {}
120
158
  json_hash['command'] = 'message'
121
159
  json_hash['identifier'] = JSON.generate(@identifier, allow_nan: true)
@@ -354,7 +392,8 @@ module OpenC3
354
392
  # PACKET - Packet name
355
393
  # VALUETYPE - RAW, CONVERTED, FORMATTED, or PURE (pure means all types as stored in log)
356
394
  #
357
- def add(items: nil, packets: nil, start_time: nil, end_time: nil, scope: $openc3_scope)
395
+ def add(items: nil, packets: nil, start_time: nil, end_time: nil, scope: nil)
396
+ scope ||= @scope
358
397
  data_hash = {}
359
398
  data_hash['action'] = 'add'
360
399
  if start_time
@@ -395,7 +434,8 @@ module OpenC3
395
434
  # PACKET - Packet name
396
435
  # VALUETYPE - RAW, CONVERTED, FORMATTED, or PURE (pure means all types as stored in log)
397
436
  #
398
- def remove(items: nil, packets: nil, scope: $openc3_scope)
437
+ def remove(items: nil, packets: nil, scope: nil)
438
+ scope ||= @scope
399
439
  data_hash = {}
400
440
  data_hash['action'] = 'remove'
401
441
  data_hash['items'] = items if items
@@ -407,7 +447,7 @@ module OpenC3
407
447
 
408
448
  # Convenience method to read all data until end marker is received.
409
449
  # Warning: DATA IS STORED IN RAM. Do not use this with large queries
410
- def self.read_all(items: nil, packets: nil, start_time: nil, end_time:, scope: $openc3_scope, timeout: nil)
450
+ def self.read_all(items: nil, packets: nil, start_time: nil, end_time:, scope: nil, timeout: nil)
411
451
  read_all_start_time = Time.now
412
452
  data = []
413
453
  self.new do |api|
@@ -102,7 +102,10 @@ module OpenC3
102
102
  def process_file(filename)
103
103
  Logger.instance.info "Processing target definition in file '#{filename}'"
104
104
  parser = ConfigParser.new("https://docs.openc3.com/docs/configuration/target")
105
- parser.parse_file(filename) do |keyword, parameters|
105
+ # run_erb is false: ERB is only rendered once, at plugin install time
106
+ # (TargetModel#deploy). At runtime target files are read already-rendered
107
+ # from the bucket, so they are treated as data, not re-executed as code.
108
+ parser.parse_file(filename, false, true, false) do |keyword, parameters|
106
109
  case keyword
107
110
  when 'LANGUAGE'
108
111
  usage = "#{keyword} <ruby | python>"
@@ -85,7 +85,10 @@ module OpenC3
85
85
  ConfigParser.new(
86
86
  'https://openc3.com/docs/tools/#table-manager-configuration-openc3--39',
87
87
  )
88
- parser.parse_file(filename) do |keyword, params|
88
+ # run_erb is false: ERB is only rendered once, at plugin install time
89
+ # (TargetModel#deploy). Table definitions are read already-rendered from the
90
+ # bucket here, so they are treated as data, not re-executed as code.
91
+ parser.parse_file(filename, false, true, false) do |keyword, params|
89
92
  if @building_generic_conversion
90
93
  case keyword
91
94
  # Complete a generic conversion
@@ -108,7 +108,7 @@ module OpenC3
108
108
  # This method is taken directly from the Rails source:
109
109
  # https://api.rubyonrails.org/v5.2/classes/ActiveStorage/Filename.html#method-i-sanitized
110
110
  # NOTE: I removed the '/' character because we have to allow this in order to traverse the path
111
- sanitized = path.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "").strip.tr("\u{202E}%$|:;\t\r\n\\", "-").gsub('..', '-')
111
+ sanitized = path.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\u{FFFD}").strip.tr("\u{202E}%$|:;\t\r\n\\", "-").gsub('..', '-')
112
112
  if sanitized != path
113
113
  raise StorageError, "Invalid path: #{path}"
114
114
  end
@@ -30,13 +30,28 @@ module OpenC3
30
30
  received_count: packet.received_count }
31
31
  # Read all RAW values at once - optimized by accessor
32
32
  json_hash = packet.read_items(packet.sorted_items)
33
+ # Items with a write conversion have already been transformed by the time the
34
+ # value lands in the buffer, so reading the buffer back does not recover what
35
+ # the user actually commanded. Use the given values (as passed to build_cmd)
36
+ # for those items. State items are the exception: the user can give either the
37
+ # state name or the state value, so they always read from the buffer to log the
38
+ # normalized state name. Raw commands bypass the write conversions entirely so
39
+ # their given values are raw and must not be logged as converted.
40
+ given_values = packet.raw ? nil : packet.given_values
41
+ given_values = given_values.transform_keys { |key| key.to_s.upcase } if given_values
33
42
  # Read additional value types using given_raw to avoid re-reading from buffer
34
43
  packet.sorted_items.each do |item|
35
44
  if item.hidden
36
45
  json_hash.delete(item.name)
37
46
  else
38
47
  given_raw = json_hash[item.name]
39
- json_hash[item.name + "__C"] = packet.read_item(item, :CONVERTED, packet.buffer, given_raw) if item.write_conversion or item.states
48
+ if item.write_conversion or item.states
49
+ if item.write_conversion and !item.states and given_values and given_values.key?(item.name)
50
+ json_hash[item.name + "__C"] = given_values[item.name]
51
+ else
52
+ json_hash[item.name + "__C"] = packet.read_item(item, :CONVERTED, packet.buffer, given_raw)
53
+ end
54
+ end
40
55
  json_hash[item.name + "__F"] = packet.read_item(item, :FORMATTED, packet.buffer, given_raw) if item.format_string
41
56
  end
42
57
  end
@@ -39,9 +39,15 @@ module OpenC3
39
39
  case event[:type]
40
40
  when :LIMITS_CHANGE
41
41
  # The current_limits hash keeps only the current limits state of items
42
- # It is used by the API to determine the overall limits state
43
- field = "#{event[:target_name]}__#{event[:packet_name]}__#{event[:item_name]}"
44
- Store.hset("#{scope}__current_limits", field, event[:new_limits_state])
42
+ # It is used by the API to determine the overall limits state.
43
+ # When the event originates from a stored packet in a non-PROCESS mode
44
+ # (LOG or DISABLE), skip updating current_limits so that historical
45
+ # data does not affect the real-time overall limits state or the
46
+ # out_of_limits API response.
47
+ unless event[:suppress_stored]
48
+ field = "#{event[:target_name]}__#{event[:packet_name]}__#{event[:item_name]}"
49
+ Store.hset("#{scope}__current_limits", field, event[:new_limits_state])
50
+ end
45
51
 
46
52
  when :LIMITS_SETTINGS
47
53
  # Limits updated in limits_api.rb to avoid circular reference to TargetModel
@@ -79,9 +85,22 @@ module OpenC3
79
85
  limits_settings['enabled'] = event[:enabled]
80
86
  Store.hset("#{scope}__current_limits_settings", field, JSON.generate(limits_settings, allow_nan: true))
81
87
 
88
+ when :LIMITS_STATE_COLOR
89
+ # Persist the state color so it survives a decom microservice restart (applied by sync_system)
90
+ field = "#{event[:target_name]}__#{event[:packet_name]}__#{event[:item_name]}"
91
+ limits_settings = Store.hget("#{scope}__current_limits_settings", field)
92
+ if limits_settings
93
+ limits_settings = JSON.parse(limits_settings, allow_nan: true, create_additions: true)
94
+ else
95
+ limits_settings = {}
96
+ end
97
+ limits_settings['state_colors'] ||= {}
98
+ limits_settings['state_colors'][event[:state_name].to_s] = event[:color].to_s
99
+ Store.hset("#{scope}__current_limits_settings", field, JSON.generate(limits_settings, allow_nan: true))
100
+
82
101
  when :LIMITS_SET
83
102
  sets = sets(scope: scope)
84
- raise "Set '#{event[:set]}' does not exist!" unless sets.key?(event[:set])
103
+ raise "Limits set '#{event[:set]}' does not exist" unless sets.key?(event[:set])
85
104
 
86
105
  # Set all existing sets to "false"
87
106
  sets = sets.transform_values! { |_key, _value| "false" }
@@ -173,6 +192,28 @@ module OpenC3
173
192
  end
174
193
  end
175
194
 
195
+ # Removes a limits set from the limits_sets hash and from the
196
+ # current_limits_settings of every item. Note that the set is not removed
197
+ # from the TargetModel packet definitions; that is cleaned up on the next
198
+ # plugin install. Running microservices will continue to hold the set in
199
+ # memory until they restart and resync from current_limits_settings.
200
+ def self.delete_set(set_name, scope:)
201
+ set_name = set_name.to_s
202
+ limits_settings = Store.hgetall("#{scope}__current_limits_settings")
203
+ # Collect all changed items and write them back in a single hmset to
204
+ # avoid a Redis round trip per item (this hash can be large)
205
+ updates = {}
206
+ limits_settings.each do |item, settings|
207
+ settings = JSON.parse(settings, allow_nan: true, create_additions: true)
208
+ if settings.key?(set_name)
209
+ settings.delete(set_name)
210
+ updates[item] = JSON.generate(settings, allow_nan: true)
211
+ end
212
+ end
213
+ Store.hmset("#{scope}__current_limits_settings", *updates.flatten) unless updates.empty?
214
+ Store.hdel("#{scope}__limits_sets", set_name)
215
+ end
216
+
176
217
  # Update the local System based on overall state
177
218
  def self.sync_system(scope:)
178
219
  all_limits_settings = Store.hgetall("#{scope}__current_limits_settings")
@@ -188,6 +229,12 @@ module OpenC3
188
229
  persistence = limits_settings['persistence_setting']
189
230
  limits_settings.each do |limits_set, settings|
190
231
  next unless Hash === settings
232
+ if limits_set == 'state_colors'
233
+ settings.each do |state_name, color|
234
+ System.limits.set_state_color(target_name, packet_name, item_name, state_name, color)
235
+ end
236
+ next
237
+ end
191
238
  System.limits.set(target_name, packet_name, item_name, settings['red_low'], settings['yellow_low'], settings['yellow_high'], settings['red_high'], settings['green_low'], settings['green_high'], limits_set.to_s.intern, persistence, enabled)
192
239
  end
193
240
  if not enabled.nil?
@@ -242,6 +289,18 @@ module OpenC3
242
289
  end
243
290
  end
244
291
 
292
+ when 'LIMITS_STATE_COLOR'
293
+ target_name = event['target_name']
294
+ packet_name = event['packet_name']
295
+ item_name = event['item_name']
296
+ target = telemetry[target_name]
297
+ if target
298
+ packet = target[packet_name]
299
+ if packet
300
+ System.limits.set_state_color(target_name, packet_name, item_name, event['state_name'], event['color'])
301
+ end
302
+ end
303
+
245
304
  when 'LIMITS_SET'
246
305
  System.limits_set = event['set']
247
306
  end
@@ -20,7 +20,7 @@ require 'openc3/utilities/open_telemetry'
20
20
 
21
21
  module OpenC3
22
22
  class TelemetryDecomTopic < Topic
23
- def self.write_packet(packet, id: nil, scope:)
23
+ def self.write_packet(packet, id: nil, include_limits_states: true, scope:)
24
24
  OpenC3.in_span("write_packet") do
25
25
  # Need to build a JSON hash of the decommutated data
26
26
  # Support "downward typing"
@@ -29,7 +29,7 @@ module OpenC3
29
29
  # If nothing - item does not exist - nil
30
30
  # __ as separators ITEM1, ITEM1__C, ITEM1__F
31
31
 
32
- json_hash = CvtModel.build_json_from_packet(packet)
32
+ json_hash = CvtModel.build_json_from_packet(packet, include_limits_states: include_limits_states)
33
33
  # Convert to JSON-safe types once and reuse for both topic write and CVT set
34
34
  json_safe_hash = json_hash.as_json
35
35
  json_data = JSON.generate(json_safe_hash, allow_nan: true)
@@ -213,6 +213,14 @@ module OpenC3
213
213
  # If the key is not found return nil
214
214
  rescue Aws::S3::Errors::NoSuchKey
215
215
  nil
216
+ # AWS S3 returns 403 AccessDenied rather than 404 NoSuchKey when the caller
217
+ # does not have s3:ListBucket on the bucket. Callers which probe for an
218
+ # optional key (i.e. TargetFile.body checking targets_modified first) would
219
+ # otherwise raise instead of falling through, so treat it as not found.
220
+ # Warn since a genuine permission problem looks identical from here.
221
+ rescue Aws::S3::Errors::AccessDenied
222
+ Logger.warn("Access denied reading #{bucket}/#{key}. Treating as not found. Verify bucket permissions if this key should exist.")
223
+ nil
216
224
  end
217
225
 
218
226
  def list_objects(bucket:, prefix: nil, max_request: 1000, max_total: 100_000)
@@ -233,7 +241,7 @@ module OpenC3
233
241
  # Array of objects with key and size methods
234
242
  result
235
243
  rescue Aws::S3::Errors::NoSuchBucket
236
- raise NotFound, "Bucket '#{bucket}' does not exist."
244
+ raise NotFound, "Bucket '#{bucket}' does not exist (list_objects)"
237
245
  end
238
246
 
239
247
  # Lists the files under a specified path
@@ -283,7 +291,7 @@ module OpenC3
283
291
  end
284
292
  result
285
293
  rescue Aws::S3::Errors::NoSuchBucket
286
- raise NotFound, "Bucket '#{bucket}' does not exist."
294
+ raise NotFound, "Bucket '#{bucket}' does not exist (list_files)"
287
295
  end
288
296
 
289
297
  # get metadata for a specific object
@@ -1,4 +1,4 @@
1
- # encoding: ascii-8bit
1
+ # encoding: utf-8
2
2
 
3
3
  # Copyright 2026 OpenC3, Inc.
4
4
  # All Rights Reserved.
@@ -478,6 +478,10 @@ RUBY
478
478
  VARIABLE #{target_name.downcase}_target_name #{target_name}
479
479
 
480
480
  TARGET #{target_name} <%= #{target_name.downcase}_target_name %>
481
+ # Decom tlm and cmd data in the time-series database is kept forever by default.
482
+ # Uncomment to enforce a 30 day retention time. See https://docs.openc3.com/docs/configuration/plugins
483
+ # CMD_DECOM_RETAIN_TIME 30d
484
+ # TLM_DECOM_RETAIN_TIME 30d
481
485
  #{interface_line}
482
486
  MAP_TARGET <%= #{target_name.downcase}_target_name %>
483
487
  DOC
@@ -59,7 +59,7 @@ module OpenC3
59
59
  # @param index [Integer] Which value to return
60
60
  # @return [Boolean] Single value converted to a boolean (true or false)
61
61
  def bool(item, index = 0)
62
- raise "#{item} not found" unless keys.include?(item)
62
+ raise "CSV key '#{item}' not found for bool conversion" unless keys.include?(item)
63
63
 
64
64
  if Range === index
65
65
  @hash[item][index].map do |x|
@@ -91,7 +91,7 @@ module OpenC3
91
91
  # @param index [Integer] Which value to return
92
92
  # @return [Integer] Single value converted to an integer
93
93
  def int(item, index = 0)
94
- raise "#{item} not found" unless keys.include?(item)
94
+ raise "CSV key '#{item}' not found for int conversion" unless keys.include?(item)
95
95
 
96
96
  if Range === index
97
97
  @hash[item][index].map { |x| x.to_i }
@@ -107,7 +107,7 @@ module OpenC3
107
107
  # @param index [Integer] Which value to return
108
108
  # @return [Float] Single value converted to a float
109
109
  def float(item, index = 0)
110
- raise "#{item} not found" unless keys.include?(item)
110
+ raise "CSV key '#{item}' not found for float conversion" unless keys.include?(item)
111
111
 
112
112
  if Range === index
113
113
  @hash[item][index].map { |x| x.to_f }
@@ -122,7 +122,7 @@ module OpenC3
122
122
  # @param index [Integer] Which value to return
123
123
  # @return [String] Single value converted to a string
124
124
  def string(item, index = 0)
125
- raise "#{item} not found" unless keys.include?(item)
125
+ raise "CSV key '#{item}' not found for string lookup" unless keys.include?(item)
126
126
 
127
127
  if Range === index
128
128
  @hash[item][index].map { |x| x.to_s }
@@ -138,7 +138,7 @@ module OpenC3
138
138
  # @param index [Integer] Which value to return
139
139
  # @return [Symbol] Single value converted to a symbol
140
140
  def symbol(item, index = 0)
141
- raise "#{item} not found" unless keys.include?(item)
141
+ raise "CSV key '#{item}' not found for symbol lookup" unless keys.include?(item)
142
142
 
143
143
  if Range === index
144
144
  @hash[item][index].map { |x| x.intern }
@@ -1,4 +1,16 @@
1
- # For simple boolean flags
1
+ # encoding: ascii-8bit
2
+
3
+ # Copyright 2026 OpenC3, Inc.
4
+ # All Rights Reserved.
5
+ #
6
+ # This program is distributed in the hope that it will be useful,
7
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
8
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
9
+ # See LICENSE.md for more details.
10
+ #
11
+ # This file may also be used under the terms of a commercial license
12
+ # if purchased from OpenC3, Inc.
13
+
2
14
  class EnvHelper
3
15
  def self.enabled?(key)
4
16
  ['true', '1', 'yes', 'on'].include?(ENV[key].to_s.downcase)
@@ -485,7 +485,10 @@ module OpenC3
485
485
  config_path = "#{OPENC3_LOCAL_MODE_PATH}/#{scope}/settings/#{name}.json"
486
486
  return unless File.expand_path(config_path).start_with?(OPENC3_LOCAL_MODE_PATH)
487
487
  FileUtils.mkdir_p(File.dirname(config_path))
488
- # Anything can be stored as a setting so write it out directly
488
+ # Anything can be stored as a setting. Strings are written directly since
489
+ # they're already the serialized form. Anything else is written as JSON so
490
+ # sync_settings can read it back rather than a Ruby inspect string.
491
+ data = JSON.generate(data.as_json(:allow_nan => true)) unless String === data
489
492
  File.write(config_path, data)
490
493
  end
491
494