openc3 7.2.0 → 7.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +11 -23
  3. data/data/config/interface_modifiers.yaml +15 -0
  4. data/data/config/target.yaml +17 -0
  5. data/data/config/widgets.yaml +28 -1
  6. data/lib/openc3/accessors/binary_accessor.rb +4 -0
  7. data/lib/openc3/api/cmd_api.rb +5 -1
  8. data/lib/openc3/api/limits_api.rb +72 -0
  9. data/lib/openc3/microservices/decom_common.rb +15 -4
  10. data/lib/openc3/microservices/decom_microservice.rb +6 -1
  11. data/lib/openc3/microservices/interface_microservice.rb +25 -11
  12. data/lib/openc3/microservices/queue_microservice.rb +4 -2
  13. data/lib/openc3/migrations/20260701000000_updated_at_to_int.rb +55 -0
  14. data/lib/openc3/models/cvt_model.rb +2 -2
  15. data/lib/openc3/models/db_sharded_model.rb +2 -2
  16. data/lib/openc3/models/interface_model.rb +16 -10
  17. data/lib/openc3/models/interface_status_model.rb +2 -2
  18. data/lib/openc3/models/metric_model.rb +2 -2
  19. data/lib/openc3/models/microservice_status_model.rb +2 -2
  20. data/lib/openc3/models/model.rb +2 -6
  21. data/lib/openc3/models/plugin_model.rb +15 -14
  22. data/lib/openc3/models/python_package_model.rb +3 -1
  23. data/lib/openc3/models/reingest_job_model.rb +1 -1
  24. data/lib/openc3/models/script_status_model.rb +73 -38
  25. data/lib/openc3/models/target_model.rb +13 -0
  26. data/lib/openc3/operators/operator.rb +15 -5
  27. data/lib/openc3/packets/commands.rb +3 -3
  28. data/lib/openc3/packets/limits.rb +23 -0
  29. data/lib/openc3/packets/packet.rb +5 -3
  30. data/lib/openc3/packets/packet_config.rb +5 -1
  31. data/lib/openc3/packets/structure.rb +14 -10
  32. data/lib/openc3/script/limits.rb +1 -1
  33. data/lib/openc3/script/suite_runner.rb +43 -9
  34. data/lib/openc3/script/web_socket_api.rb +1 -1
  35. data/lib/openc3/system/target.rb +4 -1
  36. data/lib/openc3/tools/table_manager/table_config.rb +4 -1
  37. data/lib/openc3/top_level.rb +1 -1
  38. data/lib/openc3/topics/limits_event_topic.rb +62 -3
  39. data/lib/openc3/topics/telemetry_decom_topic.rb +2 -2
  40. data/lib/openc3/utilities/cli_generator.rb +5 -1
  41. data/lib/openc3/utilities/env_helper.rb +13 -1
  42. data/lib/openc3/utilities/pypi_url.rb +39 -0
  43. data/lib/openc3/utilities/reingest_job.rb +1 -1
  44. data/lib/openc3/utilities/running_script.rb +17 -12
  45. data/lib/openc3/utilities/script.rb +14 -3
  46. data/lib/openc3/utilities/store_autoload.rb +39 -1
  47. data/lib/openc3/utilities/store_queued.rb +5 -1
  48. data/lib/openc3/version.rb +5 -5
  49. data/lib/openc3.rb +6 -1
  50. data/templates/tool_angular/package.json +2 -2
  51. data/templates/tool_react/package.json +1 -1
  52. data/templates/tool_svelte/package.json +1 -1
  53. data/templates/tool_vue/package.json +3 -3
  54. data/templates/widget/package.json +2 -2
  55. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 90e17f6bf28158ea786646171220e3c23aca07cce30559b3e64a29f83dabb50d
4
- data.tar.gz: 9a13e73273ba67e7d6003a9578a34a8b1c0abadea3b8d4d369f1fd3803a4f0ec
3
+ metadata.gz: 13ecd5ed69a40943e0ceb4732af0922106d73ba3e28c7e10521e3cbcbb2728c9
4
+ data.tar.gz: e8a55a75e964de92cdc8fa9a27091d472437004a0775a05c894d4a16d5232677
5
5
  SHA512:
6
- metadata.gz: eec5ccdb945747570717e5f60f9a89158d500d994f1fa5e6ebc81ed8bb263229950f10e1f277e0c68bf5d9fb531f3470354d45fda2164f7608c9ad22413b2022
7
- data.tar.gz: a32f7675eacf6b41b345a4b5ae554292db011499d12a9e5e65173c514e5fac7e66da3bacea7ad5cf9a3738fcb96e3a82009997993fc70722d4a9ba4edd236261
6
+ metadata.gz: 77c7ed5bcae0862bf8d7ccdeefbc79cdc814b04501831e5de5a2b7b1d685420f731da2bb230435a1d091697156ab1d58a40cf793869da48e191b7c87ccbffea9
7
+ data.tar.gz: 90571e3012ae117610314df3d32d67d77a843f2de8edb2c9b51bab3335cf0618e7301a9ba0c55616acad6a2d0ca1b4634924531f657bd438c0fd490aeca6a327
data/bin/openc3cli CHANGED
@@ -671,30 +671,18 @@ def cli_script_list(args, options)
671
671
  end
672
672
 
673
673
  def parse_suite_runner_options(options)
674
- suite_runner = nil
675
674
  # suite must be given to enable Suite Runner execution
676
- if options[:suite]
677
- suite_runner = {}
678
- suite_runner['suite'] = options[:suite]
679
- if options[:group]
680
- suite_runner['group'] = options[:group]
681
- # script requires group to be set
682
- if options[:script]
683
- suite_runner['script'] = options[:script]
684
- end
685
- end
686
- if options[:method]
687
- suite_runner['method'] = options[:method]
688
- else
689
- suite_runner['method'] = 'start'
690
- end
691
- if options[:options]
692
- suite_runner['options'] = options[:options].split(',')
693
- else
694
- suite_runner['options'] = ["continueAfterError"]
695
- end
696
- end
697
- return suite_runner
675
+ return nil unless options[:suite]
676
+ require 'openc3/script/suite_runner'
677
+ # Build the canonical hash via the shared helper so the CLI applies the same
678
+ # shape, defaults, and validation (e.g. script requires group) as execution.
679
+ return OpenC3::SuiteRunner.build_options(
680
+ suite: options[:suite],
681
+ group: options[:group],
682
+ script: options[:script],
683
+ method: options[:method],
684
+ options: options[:options]&.split(','),
685
+ )
698
686
  end
699
687
 
700
688
  def cli_script_run(args, options)
@@ -1,11 +1,16 @@
1
1
  ---
2
2
  MAP_TARGET:
3
3
  summary: Maps a target name to an interface
4
+ description: See [Mapping Targets to Interfaces](/docs/configuration/interfaces#mapping-targets-to-interfaces) for more information.
4
5
  parameters:
5
6
  - name: Target Name
6
7
  required: true
7
8
  description: Target name to map to this interface
8
9
  values: .+
10
+ - name: Enabled State
11
+ required: false
12
+ description: Initial enabled state of the target on this interface. Defaults to ENABLED.
13
+ values: ["ENABLED", "DISABLED"]
9
14
  ruby_example: |
10
15
  INTERFACE DATA_INT tcpip_client_interface.rb host.docker.internal 8080 8081 10.0 nil BURST
11
16
  MAP_TARGET DATA
@@ -14,12 +19,17 @@ MAP_TARGET:
14
19
  MAP_TARGET DATA
15
20
  MAP_CMD_TARGET:
16
21
  summary: Maps a target name to an interface for commands only
22
+ description: See [Mapping Targets to Interfaces](/docs/configuration/interfaces#mapping-targets-to-interfaces) for more information.
17
23
  since: 5.2.0
18
24
  parameters:
19
25
  - name: Target Name
20
26
  required: true
21
27
  description: Command target name to map to this interface
22
28
  values: .+
29
+ - name: Enabled State
30
+ required: false
31
+ description: Initial enabled state of the command target on this interface. Defaults to ENABLED.
32
+ values: ["ENABLED", "DISABLED"]
23
33
  ruby_example: |
24
34
  INTERFACE CMD_INT tcpip_client_interface.rb host.docker.internal 8080 8081 10.0 nil BURST
25
35
  MAP_CMD_TARGET DATA # Only DATA commands go on the CMD_INT interface
@@ -28,12 +38,17 @@ MAP_CMD_TARGET:
28
38
  MAP_CMD_TARGET DATA # Only DATA commands go on the CMD_INT interface
29
39
  MAP_TLM_TARGET:
30
40
  summary: Maps a target name to an interface for telemetry only
41
+ description: See [Mapping Targets to Interfaces](/docs/configuration/interfaces#mapping-targets-to-interfaces) for more information.
31
42
  since: 5.2.0
32
43
  parameters:
33
44
  - name: Target Name
34
45
  required: true
35
46
  description: Telemetry target name to map to this interface
36
47
  values: .+
48
+ - name: Enabled State
49
+ required: false
50
+ description: Initial enabled state of the telemetry target on this interface. Defaults to ENABLED.
51
+ values: ["ENABLED", "DISABLED"]
37
52
  ruby_example: |
38
53
  INTERFACE TLM_INT tcpip_client_interface.rb host.docker.internal 8080 8081 10.0 nil BURST
39
54
  MAP_TLM_TARGET DATA # Only DATA telemetry received on TLM_INT interface
@@ -170,3 +170,20 @@ TARGET:
170
170
  values: \d+
171
171
  example: |
172
172
  DB_SHARD 0
173
+ STORED_LIMITS_MODE:
174
+ summary: Controls how limits are evaluated for stored (non-real-time) telemetry packets
175
+ description:
176
+ Sets the limits handling policy for packets where the stored flag is true
177
+ (e.g., packets from file interfaces or historical data replay).
178
+ PROCESS processes limits normally including logging and reactions.
179
+ LOG evaluates limits and logs state changes but does not trigger limits reactions
180
+ or update the current limits state used by the API.
181
+ DISABLE skips limits processing entirely for stored packets.
182
+ since: 7.2.1
183
+ parameters:
184
+ - name: Mode
185
+ required: true
186
+ description: "PROCESS (default), LOG, or DISABLE"
187
+ values: ^(PROCESS|LOG|DISABLE)$
188
+ example: |
189
+ STORED_LIMITS_MODE DISABLE
@@ -1579,7 +1579,10 @@ Canvas Widgets:
1579
1579
  collection:
1580
1580
  CANVAS:
1581
1581
  summary: Layout widget for the other canvas widgets
1582
- description: All canvas widgets must be enclosed within a CANVAS widget.
1582
+ description:
1583
+ All canvas widgets must be enclosed within a CANVAS widget.
1584
+ The canvas background defaults to white. Use SETTING BACKCOLOR to set a
1585
+ different background color.
1583
1586
  warning: The canvas coordinate frame places (0,0) in the upper-left corner of the canvas.
1584
1587
  parameters:
1585
1588
  - name: Width
@@ -1590,6 +1593,30 @@ Canvas Widgets:
1590
1593
  required: true
1591
1594
  description: Height of the canvas
1592
1595
  values: .+
1596
+ settings:
1597
+ BACKCOLOR:
1598
+ summary: Sets the background color of the canvas
1599
+ since: 7.2.1
1600
+ parameters:
1601
+ - name: Color name or Red value
1602
+ required: true
1603
+ description: Common name for the color, e.g. 'black', 'red', etc.
1604
+ Alternatively if two more parameters are passed this is the
1605
+ Red value of the RGB value
1606
+ values: .+
1607
+ - name: Green value
1608
+ required: false
1609
+ description: Green value of the RGB value
1610
+ values: .+
1611
+ - name: Blue value
1612
+ required: false
1613
+ description: Blue value of the RGB value
1614
+ values: .+
1615
+ example: |
1616
+ CANVAS 100 50
1617
+ SETTING BACKCOLOR 17 21 28
1618
+ CANVASLABEL 5 30 "Dark canvas" 18 white
1619
+ END
1593
1620
  CANVASLABEL:
1594
1621
  summary: Draws text onto the canvas
1595
1622
  parameters:
@@ -108,6 +108,10 @@ module OpenC3
108
108
 
109
109
  def handle_read_variable_bit_size(item, _buffer)
110
110
  length_value = @packet.read(item.variable_bit_size['length_item_name'], :CONVERTED)
111
+ # length_value can be nil when reading an undersized packet where the length
112
+ # item falls outside the buffer bounds
113
+ raise "Length value #{item.variable_bit_size['length_item_name']} for item #{item.name} is nil" if length_value.nil?
114
+
111
115
  if item.array_size
112
116
  item.array_size = (length_value * item.variable_bit_size['length_bits_per_count']) + item.variable_bit_size['length_value_bit_offset']
113
117
  else
@@ -450,7 +450,7 @@ module OpenC3
450
450
 
451
451
  # NOTE: When adding new keywords to this method, make sure to update script/commands.rb
452
452
  def _cmd_implementation(method_name, *args, range_check:, hazardous_check:, raw:, timeout: nil, log_message: nil, manual: false, validate: true, queue: nil,
453
- scope: $openc3_scope, token: $openc3_token, **kwargs)
453
+ queue_username: nil, scope: $openc3_scope, token: $openc3_token, **kwargs)
454
454
  extract_string_kwargs_to_args(args, kwargs)
455
455
  unless [nil, true, false].include?(log_message)
456
456
  raise "Invalid log_message parameter: #{log_message}. Must be true or false."
@@ -535,6 +535,10 @@ module OpenC3
535
535
  'log_message' => log_message.to_s,
536
536
  'obfuscated_items' => packet['obfuscated_items'].to_s
537
537
  }
538
+ # Record the original queuing user (author) separately from 'username' (the
539
+ # user or process that actually executed the command). Command History shows
540
+ # 'username' as "Executed By" and queue_username as "Queued By".
541
+ command['queue_username'] = queue_username if queue_username
538
542
  # Users have to explicitly opt into a default queue by setting the OPENC3_DEFAULT_QUEUE
539
543
  # At which point ALL commands will go to that queue unless they specifically opt out with queue: false
540
544
  if ENV['OPENC3_DEFAULT_QUEUE'] && queue.nil?
@@ -17,6 +17,7 @@
17
17
 
18
18
  require 'openc3/api/target_api'
19
19
  require 'openc3/models/cvt_model'
20
+ require 'openc3/packets/packet_item'
20
21
 
21
22
  module OpenC3
22
23
  module Api
@@ -29,12 +30,14 @@ module OpenC3
29
30
  'disable_limits',
30
31
  'get_limits',
31
32
  'set_limits',
33
+ 'set_state_color',
32
34
  'get_limits_groups',
33
35
  'enable_limits_group',
34
36
  'disable_limits_group',
35
37
  'get_limits_sets',
36
38
  'set_limits_set',
37
39
  'get_limits_set',
40
+ 'delete_limits_set',
38
41
  'get_limits_events',
39
42
  ])
40
43
 
@@ -251,6 +254,50 @@ module OpenC3
251
254
  LimitsEventTopic.write(event, scope: scope)
252
255
  end
253
256
 
257
+ # Change the color associated with a telemetry item state in realtime.
258
+ # Items with states use the state color (GREEN, YELLOW, RED) to determine
259
+ # their limits state rather than numeric red/yellow/green limits.
260
+ #
261
+ # @param target_name [String] Target name
262
+ # @param packet_name [String] Packet name
263
+ # @param item_name [String] Item name
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.
266
+ def set_state_color(target_name, packet_name, item_name, state_name, color,
267
+ manual: false, scope: $openc3_scope, token: $openc3_token)
268
+ authorize(permission: 'tlm_set', target_name: target_name, packet_name: packet_name, manual: manual, scope: scope, token: token)
269
+ 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
+ end
274
+ packet = TargetModel.packet(target_name, packet_name, scope: scope)
275
+ found_item = nil
276
+ packet['items'].each do |item|
277
+ if item['name'] == item_name
278
+ unless item['states'] && item['states'][state_name]
279
+ raise "State '#{state_name}' does not exist for item '#{target_name} #{packet_name} #{item_name}'"
280
+ 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
285
+ found_item = item
286
+ break
287
+ end
288
+ 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}"
291
+ Logger.info(message, scope: scope)
292
+
293
+ TargetModel.set_packet(target_name, packet_name, packet, scope: scope)
294
+
295
+ event = { type: :LIMITS_STATE_COLOR, target_name: target_name, packet_name: packet_name,
296
+ item_name: item_name, state_name: state_name, color: color,
297
+ time_nsec: Time.now.to_nsec_from_epoch, message: message }
298
+ LimitsEventTopic.write(event, scope: scope)
299
+ end
300
+
254
301
  # Returns all limits_groups and their members
255
302
  # @since 5.0.0 Returns hash with values
256
303
  # @return [Hash{String => Array<Array<String, String, String>>]
@@ -292,6 +339,31 @@ module OpenC3
292
339
  time_nsec: Time.now.to_nsec_from_epoch, message: message }, scope: scope)
293
340
  end
294
341
 
342
+ # Deletes a limits set. The DEFAULT limits set and the currently active
343
+ # limits set cannot be deleted. Use set_limits_set to change the active set
344
+ # before deleting it. Note that the limits set is not removed from the
345
+ # TargetModel packet definitions; that is cleaned up on the next plugin
346
+ # install.
347
+ #
348
+ # @param limits_set [String] The name of the limits set to delete
349
+ def delete_limits_set(limits_set, manual: false, scope: $openc3_scope, token: $openc3_token)
350
+ authorize(permission: 'tlm_set', manual: manual, scope: scope, token: token)
351
+ limits_set = limits_set.to_s
352
+ raise "Cannot delete the DEFAULT limits set" if limits_set == 'DEFAULT'
353
+ if limits_set == LimitsEventTopic.current_set(scope: scope)
354
+ raise "Cannot delete the current limits set '#{limits_set}'. Use set_limits_set to change the current set first."
355
+ end
356
+ unless LimitsEventTopic.sets(scope: scope).key?(limits_set)
357
+ raise "Limits set '#{limits_set}' does not exist"
358
+ end
359
+
360
+ # Remove the limits set from Redis (limits_sets and current_limits_settings)
361
+ LimitsEventTopic.delete_set(limits_set, scope: scope)
362
+
363
+ message = "Deleting Limits Set: #{limits_set}"
364
+ Logger.info(message, scope: scope)
365
+ end
366
+
295
367
  # Returns the active limits set that applies to all telemetry
296
368
  #
297
369
  # @return [String] The current limits set
@@ -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.
@@ -35,13 +35,19 @@ module OpenC3
35
35
  # (microservice name, or "REINGEST:<job_id>").
36
36
  # @param check_limits [Boolean] When false, skips the Packet#check_limits call.
37
37
  # Reingest passes false so historical data does not re-fire limits events.
38
+ # @param stored_limits_mode [String] Controls limits handling for stored packets.
39
+ # 'PROCESS' (default) processes limits normally.
40
+ # 'LOG' still evaluates limits (callback handles suppressing reactions).
41
+ # 'DISABLE' skips limits evaluation entirely for stored packets and omits
42
+ # limits states from the decommutated output.
38
43
  # @param metric [Metric, nil] Optional; when set, records decom_duration_seconds.
39
44
  # @param error_callback [Proc, nil] Called as error_callback.call(exception) when
40
45
  # Packet#process or Packet#check_limits raises. The microservice uses this to
41
46
  # bump its decom_error_total metric.
42
47
  # @return [Integer] Number of (sub)packets published.
43
48
  def decom_and_publish(packet, scope:, target_names:, logger:, name:,
44
- check_limits: true, metric: nil, error_callback: nil)
49
+ check_limits: true, stored_limits_mode: 'PROCESS',
50
+ metric: nil, error_callback: nil)
45
51
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC) if metric
46
52
  published = 0
47
53
 
@@ -62,9 +68,14 @@ module OpenC3
62
68
  logger.error e.message
63
69
  end
64
70
 
65
- packet_or_subpacket.check_limits(System.limits_set) if check_limits
71
+ disable_stored_limits = packet_or_subpacket.stored && stored_limits_mode == 'DISABLE'
72
+ if check_limits && !disable_stored_limits
73
+ packet_or_subpacket.check_limits(System.limits_set)
74
+ end
66
75
 
67
- TelemetryDecomTopic.write_packet(packet_or_subpacket, scope: scope)
76
+ TelemetryDecomTopic.write_packet(packet_or_subpacket,
77
+ include_limits_states: !disable_stored_limits,
78
+ scope: scope)
68
79
  published += 1
69
80
  end
70
81
 
@@ -100,6 +100,8 @@ module OpenC3
100
100
  @limits_response_thread = nil
101
101
  System.telemetry.limits_change_callback = method(:limits_change_callback)
102
102
  LimitsEventTopic.sync_system(scope: @scope)
103
+ target_model = TargetModel.get_model(name: @target_names[0], scope: @scope)
104
+ @stored_limits_mode = target_model ? (target_model.stored_limits_mode || 'PROCESS') : 'PROCESS'
103
105
  @error_count = 0
104
106
  @metric.set(name: 'decom_total', value: @count, type: 'counter')
105
107
  @metric.set(name: 'decom_error_total', value: @error_count, type: 'counter')
@@ -184,6 +186,7 @@ module OpenC3
184
186
  logger: @logger,
185
187
  name: @name,
186
188
  check_limits: true,
189
+ stored_limits_mode: @stored_limits_mode,
187
190
  metric: @metric,
188
191
  error_callback: ->(e) {
189
192
  @error_count += 1
@@ -253,9 +256,11 @@ module OpenC3
253
256
  event = { type: :LIMITS_CHANGE, target_name: packet.target_name, packet_name: packet.packet_name,
254
257
  item_name: item.name, old_limits_state: old_limits_state.to_s, new_limits_state: item.limits.state.to_s,
255
258
  time_nsec: packet_time.to_nsec_from_epoch, message: message.to_s }
259
+ suppress_stored = packet.stored && @stored_limits_mode != 'PROCESS'
260
+ event[:suppress_stored] = true if suppress_stored
256
261
  LimitsEventTopic.write(event, scope: @scope)
257
262
 
258
- if item.limits.response
263
+ if item.limits.response && !suppress_stored
259
264
  copied_packet = packet.deep_copy
260
265
  copied_item = packet.items[item.name]
261
266
  @limits_response_queue << [copied_packet, copied_item, old_limits_state]
@@ -272,6 +272,11 @@ module OpenC3
272
272
  else
273
273
  next nil # Don't ack disabled targets
274
274
  end
275
+ rescue RangeError => e
276
+ # Command parameter out of range is a user error, not a bug,
277
+ # so only log the message and not the full stack trace
278
+ @logger.error "#{@interface.name}: #{e.message}"
279
+ next e.message
275
280
  rescue => e
276
281
  @logger.error "#{@interface.name}: #{msg_hash}"
277
282
  @logger.error "#{@interface.name}: #{e.formatted}"
@@ -281,6 +286,9 @@ module OpenC3
281
286
  command.extra ||= {}
282
287
  command.extra['cmd_string'] = msg_hash['cmd_string']
283
288
  command.extra['username'] = msg_hash['username']
289
+ command.extra['interface_name'] = @interface.name
290
+ # Original author of a queued command (set when released from a queue)
291
+ command.extra['queue_username'] = msg_hash['queue_username'] if msg_hash['queue_username']
284
292
  # Add approver info if this was a critical command that was approved
285
293
  if critical_model
286
294
  command.extra['approver'] = critical_model.approver
@@ -706,16 +714,19 @@ module OpenC3
706
714
  # Try to do clean disconnect because we're going down
707
715
  disconnect(false)
708
716
  end
709
- if @interface_or_router == 'INTERFACE'
710
- InterfaceStatusModel.set(@interface.as_json(), queued: true, scope: @scope)
711
- else
712
- RouterStatusModel.set(@interface.as_json(), queued: true, scope: @scope)
717
+ unless @cancel_thread
718
+ if @interface_or_router == 'INTERFACE'
719
+ InterfaceStatusModel.set(@interface.as_json(), queued: true, scope: @scope)
720
+ else
721
+ RouterStatusModel.set(@interface.as_json(), queued: true, scope: @scope)
722
+ end
713
723
  end
714
724
  @logger.info "#{@interface.name}: Stopped packet reading"
715
725
  end
716
726
 
717
727
  def handle_packet(packet)
718
- InterfaceStatusModel.set(@interface.as_json(), queued: true, scope: @scope)
728
+ # Skip status update if stop() has been called to avoid re-creating the status model
729
+ InterfaceStatusModel.set(@interface.as_json(), queued: true, scope: @scope) unless @cancel_thread
719
730
  packet.received_time = Time.now.sys unless packet.received_time
720
731
 
721
732
  if packet.stored
@@ -853,7 +864,8 @@ module OpenC3
853
864
 
854
865
  # If the interface is set to auto_reconnect then delay so the thread
855
866
  # can come back around and allow the interface a chance to reconnect.
856
- if allow_reconnect and @interface.auto_reconnect and @interface.state != 'DISCONNECTED'
867
+ # Skip reconnect if stop() has been called to avoid re-creating the status model
868
+ if allow_reconnect and @interface.auto_reconnect and @interface.state != 'DISCONNECTED' and !@cancel_thread
857
869
  attempting()
858
870
  if !@cancel_thread
859
871
  # @logger.debug "reconnect delay: #{@interface.reconnect_delay}"
@@ -861,10 +873,12 @@ module OpenC3
861
873
  end
862
874
  else
863
875
  @interface.state = 'DISCONNECTED'
864
- if @interface_or_router == 'INTERFACE'
865
- InterfaceStatusModel.set(@interface.as_json(), queued: true, scope: @scope)
866
- else
867
- RouterStatusModel.set(@interface.as_json(), queued: true, scope: @scope)
876
+ unless @cancel_thread
877
+ if @interface_or_router == 'INTERFACE'
878
+ InterfaceStatusModel.set(@interface.as_json(), queued: true, scope: @scope)
879
+ else
880
+ RouterStatusModel.set(@interface.as_json(), queued: true, scope: @scope)
881
+ end
868
882
  end
869
883
  end
870
884
  end
@@ -914,4 +928,4 @@ if __FILE__ == $0
914
928
  OpenC3::InterfaceMicroservice.run
915
929
  OpenC3::ThreadManager.instance.shutdown
916
930
  OpenC3::ThreadManager.instance.join
917
- end
931
+ end
@@ -76,12 +76,14 @@ module OpenC3
76
76
  end
77
77
  validate = command.key?('validate') ? command['validate'] : true
78
78
  timeout = command['timeout']
79
- cmd(command['target_name'], command['cmd_name'], cmd_params, queue: false, validate: validate, timeout: timeout, scope: @scope)
79
+ # Pass queue_username so Command History is attributed to the original
80
+ # author rather than the queue microservice name
81
+ cmd(command['target_name'], command['cmd_name'], cmd_params, queue: false, validate: validate, timeout: timeout, queue_username: command['username'], scope: @scope)
80
82
  elsif command['value']
81
83
  # Legacy format: use single string parameter for backwards compatibility
82
84
  validate = command.key?('validate') ? command['validate'] : true
83
85
  timeout = command['timeout']
84
- cmd(command['value'], queue: false, validate: validate, timeout: timeout, scope: @scope)
86
+ cmd(command['value'], queue: false, validate: validate, timeout: timeout, queue_username: command['username'], scope: @scope)
85
87
  else
86
88
  @logger.error "QueueProcessor: Invalid command format, missing required fields"
87
89
  end
@@ -0,0 +1,55 @@
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
+
14
+ require 'time'
15
+ require 'openc3/utilities/migration'
16
+ require 'openc3/core_ext/time'
17
+ require 'openc3/models/scope_model'
18
+ require 'openc3/models/script_status_model'
19
+
20
+ module OpenC3
21
+ # Earlier versions of the Python script runner wrote the ScriptStatusModel
22
+ # updated_at field as an ISO8601 string (e.g. "2026-06-30T12:34:56.789012Z")
23
+ # instead of integer nanoseconds since the epoch. This migration rewrites any
24
+ # such string values as integers so updated_at is consistently an int.
25
+ #
26
+ # NOTE: The stored JSON is edited in place rather than reloaded through the
27
+ # model and re-created, because ScriptStatusModel#create always sets
28
+ # updated_at to the current time, which would discard the original timestamp.
29
+ class UpdatedAtToInt < Migration
30
+ def self.run
31
+ ScopeModel.names.each do |scope|
32
+ [ScriptStatusModel::RUNNING_PRIMARY_KEY, ScriptStatusModel::COMPLETED_PRIMARY_KEY].each do |key|
33
+ primary_key = "#{key}__#{scope}"
34
+ Store.hgetall(primary_key).each do |name, json|
35
+ data = JSON.parse(json, allow_nan: true)
36
+ updated_at = data['updated_at']
37
+ next unless updated_at.is_a?(String)
38
+
39
+ begin
40
+ data['updated_at'] = Time.parse(updated_at).to_nsec_from_epoch
41
+ rescue ArgumentError
42
+ # Leave unparsable values untouched
43
+ next
44
+ end
45
+ Store.hset(primary_key, name, JSON.generate(data, allow_nan: true))
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ unless ENV['OPENC3_NO_MIGRATE']
54
+ OpenC3::UpdatedAtToInt.run
55
+ end
@@ -26,8 +26,8 @@ module OpenC3
26
26
  @@override_cache = {}
27
27
 
28
28
  VALUE_TYPES = [:RAW, :CONVERTED, :FORMATTED]
29
- def self.build_json_from_packet(packet)
30
- packet.decom
29
+ def self.build_json_from_packet(packet, include_limits_states: true)
30
+ packet.decom(include_limits_states: include_limits_states)
31
31
  end
32
32
 
33
33
  # Get a Store instance routed to the correct db_shard for a target
@@ -79,7 +79,7 @@ module OpenC3
79
79
  end
80
80
 
81
81
  # DB_Shard-aware create: writes to the store instance for the given db_shard.
82
- def _db_sharded_create(db_shard, update: false, force: false, queued: false, isoformat: false, expire_seconds: nil)
82
+ def _db_sharded_create(db_shard, update: false, force: false, queued: false, expire_seconds: nil)
83
83
  db_shard_store = self.class.store.instance(db_shard: db_shard)
84
84
  unless force
85
85
  existing = db_shard_store.hget(@primary_key, @name)
@@ -89,7 +89,7 @@ module OpenC3
89
89
  raise RuntimeError.new("#{@primary_key}:#{@name} doesn't exist at update") if update
90
90
  end
91
91
  end
92
- @updated_at = isoformat ? Time.now.utc.iso8601 : Time.now.utc.to_nsec_from_epoch
92
+ @updated_at = Time.now.utc.to_nsec_from_epoch
93
93
 
94
94
  if queued
95
95
  store = self.class.store_queued.instance(db_shard: db_shard)
@@ -444,9 +444,11 @@ module OpenC3
444
444
  microservice
445
445
  end
446
446
 
447
- # Looks up the deployed MicroserviceModel and destroy the microservice model
448
- # should should trigger the operator to kill the microservice that in turn
449
- # will destroy the InterfaceStatusModel when a stop is called.
447
+ # Destroys the deployed MicroserviceModel which triggers the operator to kill
448
+ # the microservice. Also directly destroys the Interface/RouterStatusModel in
449
+ # an ensure block. The microservice stop() no longer recreates the status
450
+ # model once cancel_thread is set, so undeploy must clean it up itself to
451
+ # avoid leaving an orphaned status model behind.
450
452
  def undeploy
451
453
  type = self.class._get_type
452
454
  name = "#{@scope}__#{type}__#{@name}"
@@ -455,15 +457,19 @@ module OpenC3
455
457
  model.destroy
456
458
  ConfigTopic.write({ kind: 'deleted', type: type.downcase, name: @name, plugin: @plugin }, scope: @scope)
457
459
  end
458
-
459
- if type == 'INTERFACE'
460
- status_model = InterfaceStatusModel.get_model(name: @name, scope: @scope)
461
- else
462
- status_model = RouterStatusModel.get_model(name: @name, scope: @scope)
463
- end
464
- status_model.destroy if status_model
465
460
  rescue Exception => error
466
461
  Logger.error("Error undeploying interface/router model #{@name} in scope #{@scope} due to #{error}")
462
+ ensure
463
+ begin
464
+ if type == 'INTERFACE'
465
+ status_model = InterfaceStatusModel.get_model(name: @name, scope: @scope)
466
+ else
467
+ status_model = RouterStatusModel.get_model(name: @name, scope: @scope)
468
+ end
469
+ status_model.destroy if status_model
470
+ rescue Exception => error
471
+ Logger.error("Error destroying #{type&.downcase || 'unknown'} status model #{@name} in scope #{@scope} due to #{error}")
472
+ end
467
473
  end
468
474
 
469
475
  def unmap_target(target_name, cmd_only: false, tlm_only: false)
@@ -118,8 +118,8 @@ module OpenC3
118
118
  @rxcnt = rxcnt
119
119
  end
120
120
 
121
- def create(update: false, force: false, queued: false, isoformat: false)
122
- _db_sharded_create(self.class._db_shard_for_name(@name, scope: @scope, use_cache: true), update: update, force: force, queued: queued, isoformat: isoformat)
121
+ def create(update: false, force: false, queued: false)
122
+ _db_sharded_create(self.class._db_shard_for_name(@name, scope: @scope, use_cache: true), update: update, force: force, queued: queued)
123
123
  end
124
124
 
125
125
  def destroy
@@ -77,8 +77,8 @@ module OpenC3
77
77
  @db_shard = db_shard.to_i
78
78
  end
79
79
 
80
- def create(update: false, force: false, queued: false, isoformat: false, expire_seconds: nil)
81
- _db_sharded_create(@db_shard, update: update, force: force, queued: queued, isoformat: isoformat, expire_seconds: expire_seconds)
80
+ def create(update: false, force: false, queued: false, expire_seconds: nil)
81
+ _db_sharded_create(@db_shard, update: update, force: force, queued: queued, expire_seconds: expire_seconds)
82
82
  end
83
83
 
84
84
  def destroy
@@ -76,8 +76,8 @@ module OpenC3
76
76
  @custom = custom
77
77
  end
78
78
 
79
- def create(update: false, force: false, queued: false, isoformat: false)
80
- _db_sharded_create(self.class._db_shard_for_name(@name, scope: @scope, use_cache: true), update: update, force: force, queued: queued, isoformat: isoformat)
79
+ def create(update: false, force: false, queued: false)
80
+ _db_sharded_create(self.class._db_shard_for_name(@name, scope: @scope, use_cache: true), update: update, force: force, queued: queued)
81
81
  end
82
82
 
83
83
  def destroy