From 608b5aed873409a666ebaefef847731444df2561 Mon Sep 17 00:00:00 2001 From: Callie Date: Wed, 21 Oct 2026 16:58:48 -0600 Subject: [PATCH 1/7] feat(demo): add MEMDUMP memory dump with CRC checks and recovery - Add INST/INST2 MEMDUMP command whose CRC over OFFSET and SIZE is set by memdump_cmd_crc_conversion and checked by sim_inst - Add MEMDUMP telemetry with a variable DATA block, the target CRC and ground CALC_CRC/CRC_OK items from crc32_conversion - Add MEMDUMP_FAULT so sim_inst can flip a bit in or drop the next dump - Add memdump procedures that retry bad or missing chunks, wait out link loss and resume from a bucket checkpoint before put_target_file - Add a MEMDUMP screen to send dumps, inject faults and run the script Co-Authored-By: Claude --- .../targets/INST/cmd_tlm/inst_cmds.txt | 18 ++ .../targets/INST/cmd_tlm/inst_tlm.txt | 24 +++ .../targets/INST/lib/crc32_conversion.rb | 38 ++++ .../INST/lib/memdump_cmd_crc_conversion.rb | 41 ++++ .../targets/INST/lib/sim_inst.rb | 49 +++++ .../targets/INST/procedures/memdump.rb | 172 ++++++++++++++++ .../targets/INST/screens/memdump.txt | 44 +++++ .../targets/INST2/cmd_tlm/inst_cmds.txt | 18 ++ .../targets/INST2/cmd_tlm/inst_tlm.txt | 24 +++ .../targets/INST2/lib/crc32_conversion.py | 32 +++ .../INST2/lib/memdump_cmd_crc_conversion.py | 37 ++++ .../targets/INST2/lib/sim_inst.py | 50 +++++ .../targets/INST2/procedures/memdump.py | 186 ++++++++++++++++++ .../targets/INST2/screens/memdump.txt | 44 +++++ 14 files changed, 777 insertions(+) create mode 100644 openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/crc32_conversion.rb create mode 100644 openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb create mode 100644 openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb create mode 100644 openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt create mode 100644 openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/crc32_conversion.py create mode 100644 openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/memdump_cmd_crc_conversion.py create mode 100644 openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/memdump.py create mode 100644 openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/cmd_tlm/inst_cmds.txt b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/cmd_tlm/inst_cmds.txt index 7f7214c3f9..ddc299b959 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/cmd_tlm/inst_cmds.txt +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/cmd_tlm/inst_cmds.txt @@ -279,6 +279,24 @@ COMMAND <%= target_name %> BLOCK BIG_ENDIAN "Send variable block of data" HIDDEN RELATED_ITEM <%= target_name %> HEALTH_STATUS BLOCKTEST +COMMAND <%= target_name %> MEMDUMP BIG_ENDIAN "Dump a block of simulated memory" + <%= render "_ccsds_cmd.txt", locals: {id: 17} %> + APPEND_PARAMETER OFFSET 32 UINT MIN MAX 0 "Memory offset in bytes to start the dump" + APPEND_PARAMETER SIZE 32 UINT 1 4096 1024 "Number of bytes to dump" + UNITS Bytes B + APPEND_PARAMETER CRC 32 UINT MIN MAX 0 "CRC32 over OFFSET and SIZE (calculated automatically, use cmd_raw to override)" + FORMAT_STRING "0x%08X" + WRITE_CONVERSION memdump_cmd_crc_conversion.rb + RELATED_ITEM <%= target_name %> MEMDUMP DATA + +COMMAND <%= target_name %> MEMDUMP_FAULT BIG_ENDIAN "Inject a fault into the next MEMDUMP response to simulate a bad downlink" + VALIDATOR inst_cmd_validator.rb + <%= render "_ccsds_cmd.txt", locals: {id: 18} %> + APPEND_PARAMETER TYPE 8 UINT 0 1 0 "Type of fault to inject" + STATE BITFLIP 0 # Flip a random bit in the data after the CRC is calculated + STATE DROP 1 # Discard the response so it never arrives + RELATED_ITEM <%= target_name %> MEMDUMP CRC_OK + # Throughput Testing Commands (for use with external throughput server) COMMAND <%= target_name %> START_STREAM BIG_ENDIAN "Start telemetry streaming from throughput server" <%= render "_ccsds_cmd.txt", locals: {id: 200} %> diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/cmd_tlm/inst_tlm.txt b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/cmd_tlm/inst_tlm.txt index 5ebcc75b0a..ad9975b227 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/cmd_tlm/inst_tlm.txt +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/cmd_tlm/inst_tlm.txt @@ -215,6 +215,30 @@ TELEMETRY <%= target_name %> VARIABLE_ARRAYS BIG_ENDIAN "Variable length arrays" READ_CONVERSION openc3/conversions/unix_time_conversion.py TIMESEC TIMEUS CONVERTED_DATA 280 STRING +TELEMETRY <%= target_name %> MEMDUMP BIG_ENDIAN "Memory dump sent in response to the MEMDUMP command" + <%= render "_ccsds_tlm.txt", locals: {apid: 8} %> + APPEND_ITEM OFFSET 32 UINT "Memory offset in bytes of the dump" + APPEND_ITEM SIZE 32 UINT "Number of bytes in the dump" + UNITS Bytes B + APPEND_ITEM DATA 0 BLOCK "Dumped memory" + VARIABLE_BIT_SIZE SIZE + APPEND_ITEM CRC 32 UINT "CRC32 of DATA calculated by the target" + FORMAT_STRING "0x%08X" + ITEM CALC_CRC 0 0 DERIVED "CRC32 of DATA calculated on the ground" + READ_CONVERSION crc32_conversion.rb DATA + CONVERTED_DATA 32 UINT + FORMAT_STRING "0x%08X" + ITEM CRC_OK 0 0 DERIVED "Whether the target CRC matches the ground CRC" + GENERIC_READ_CONVERSION_START + packet.read('CRC') == packet.read('CALC_CRC') ? 1 : 0 + GENERIC_READ_CONVERSION_END + CONVERTED_DATA 8 UINT + STATE TRUE 1 GREEN + STATE FALSE 0 RED + ITEM PACKET_TIME 0 0 DERIVED "Ruby time based on TIMESEC and TIMEUS" + READ_CONVERSION unix_time_conversion.rb TIMESEC TIMEUS + CONVERTED_DATA 280 STRING + TELEMETRY <%= target_name %> JSONTLM BIG_ENDIAN "JSON Accessor Telemetry" ACCESSOR JsonAccessor # Template is not required for telemetry, but is useful for simulation diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/crc32_conversion.rb b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/crc32_conversion.rb new file mode 100644 index 0000000000..32be1b1455 --- /dev/null +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/crc32_conversion.rb @@ -0,0 +1,38 @@ +# encoding: ascii-8bit + +# Copyright 2026 OpenC3, Inc. +# All Rights Reserved. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See LICENSE.md for more details. +# +# This file may also be used under the terms of a commercial license +# if purchased from OpenC3, Inc. + +require 'openc3/conversions/conversion' +require 'openc3/utilities/crc' + +module OpenC3 + # Read conversion which calculates the CRC32 of another item in the packet. + # Used to verify the CRC sent by the target. + # See https://docs.openc3.com/docs/configuration/conversions + class Crc32Conversion < Conversion + # @param item_name [String] Name of the item to calculate the CRC over + def initialize(item_name) + super() + @item_name = item_name.to_s.upcase + @crc = Crc32.new + @params = [@item_name] + end + + # @param value [Object] Value based on the item definition. This could be + # a string, integer, float, or array of values. + # @param packet [Packet] The packet object where the conversion is defined + # @param buffer [String] The raw packet buffer + def call(value, packet, buffer) + @crc.calc(packet.read(@item_name, :RAW, buffer)) + end + end +end diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb new file mode 100644 index 0000000000..4efb286ba9 --- /dev/null +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb @@ -0,0 +1,41 @@ +# encoding: ascii-8bit + +# Copyright 2026 OpenC3, Inc. +# All Rights Reserved. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See LICENSE.md for more details. +# +# This file may also be used under the terms of a commercial license +# if purchased from OpenC3, Inc. + +require 'openc3/conversions/conversion' +require 'openc3/utilities/crc' + +module OpenC3 + # Write conversion which calculates the MEMDUMP command CRC32 over the + # big endian OFFSET and SIZE parameters so the user never has to. + # Send the command with cmd_raw to bypass this conversion and set a bad CRC. + # See https://docs.openc3.com/docs/configuration/conversions + class MemdumpCmdCrcConversion < Conversion + def initialize + super() + @crc = Crc32.new + end + + # @param value [Object] Value based on the item definition. This could be + # a string, integer, float, or array of values. + # @param packet [Packet] The packet object where the conversion is defined + # @param buffer [String] The raw packet buffer + def call(value, packet, buffer) + # Write conversions run before the given parameters are written to the + # buffer so use the given values and fall back to the defaults + given = packet.given_values || {} + offset = Integer(given['OFFSET'] || packet.get_item('OFFSET').default) + size = Integer(given['SIZE'] || packet.get_item('SIZE').default) + @crc.calc([offset, size].pack('NN')) + end + end +end diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/sim_inst.rb b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/sim_inst.rb index 317f73cbdd..d7eb8e38cf 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/sim_inst.rb +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/sim_inst.rb @@ -21,6 +21,7 @@ require 'stringio' require 'base64' require 'openc3/accessors/binary_accessor' +require 'openc3/utilities/crc' module OpenC3 # Simulated instrument for the demo. Populates several packets and cycles @@ -128,6 +129,10 @@ def initialize(target_name) packet.CcsdsSeqFlags = 'NOGROUP' packet.CcsdsLength = packet.buffer.length - 7 + packet = @tlm_packets['MEMDUMP'] + packet.enable_method_missing + packet.CcsdsSeqFlags = 'NOGROUP' + @solar_panel_positions = SOLAR_PANEL_DFLTS.dup @solar_panel_thread = nil @solar_panel_thread_cancel = false @@ -150,6 +155,9 @@ def initialize(target_name) @time_offset = 0 @ip_address = 0 @variable_arrays_updated = false + @memdump_pending = false + @memdump_fault = nil + @crc = Crc32.new end def set_rates @@ -269,6 +277,37 @@ def write(packet) # Write only arrays - variable_bit_size will auto-update length fields arrays_packet.write('ARRAY1', packet.read('ARRAY1')) arrays_packet.write('ARRAY2', packet.read('ARRAY2')) + when 'MEMDUMP' + offset = packet.read('OFFSET') + size = packet.read('SIZE') + # Reject the command if the CRC over OFFSET and SIZE doesn't match + return unless packet.read('CRC') == @crc.calc([offset, size].pack('NN')) + # Reject sizes outside the command's range since cmd_raw skips range checks + return unless (1..4096).cover?(size) + hs_packet.cmd_acpt_cnt += 1 + # Simulated memory is a byte counter where each byte holds its address + data = Array.new(size) { |i| (offset + i) & 0xFF }.pack('C*') + memdump_packet = @tlm_packets['MEMDUMP'] + memdump_packet.write('OFFSET', offset) + # Writing DATA resizes the packet and updates SIZE due to VARIABLE_BIT_SIZE + memdump_packet.write('DATA', data) + memdump_packet.write('CRC', @crc.calc(data)) + fault = @memdump_fault + @memdump_fault = nil + case fault + when 'BITFLIP' + # Flip a random bit after calculating the CRC to simulate downlink corruption + bit = rand(size * 8) + data.setbyte(bit / 8, data.getbyte(bit / 8) ^ (0x80 >> (bit % 8))) + memdump_packet.write('DATA', data) + when 'DROP' + # The command was accepted but the response is lost on the downlink + return + end + @memdump_pending = true + when 'MEMDUMP_FAULT' + hs_packet.cmd_acpt_cnt += 1 + @memdump_fault = packet.read('TYPE') end end @@ -469,6 +508,16 @@ def read(count_100hz, time) pending_packets << arrays_packet end + if @memdump_pending + @memdump_pending = false + memdump_packet = @tlm_packets['MEMDUMP'] + memdump_packet.write('CCSDSLENGTH', memdump_packet.buffer(false).length - 7) + memdump_packet.write('TIMESEC', time.tv_sec - @time_offset) + memdump_packet.write('TIMEUS', time.tv_usec) + memdump_packet.write('CCSDSSEQCNT', memdump_packet.read('CCSDSSEQCNT') + 1) + pending_packets << memdump_packet + end + pending_packets end end diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb new file mode 100644 index 0000000000..b0be3660f2 --- /dev/null +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb @@ -0,0 +1,172 @@ +# Copyright 2026 OpenC3, Inc. +# All Rights Reserved. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See LICENSE.md for more details. +# +# This file may also be used under the terms of a commercial license +# if purchased from OpenC3, Inc. + +# Dump a region of INST memory with the MEMDUMP command, verify each chunk +# and then push the dump to the bucket with put_target_file. +# +# The dump recovers from a bad downlink: +# * Chunks with a bad CRC or no response are requested again +# * If the link is down the script waits for it to come back +# * Progress is checkpointed to the bucket so an interrupted dump resumes +# where it left off the next time the script runs +# +# Environment variables: +# FAULT=BITFLIP or FAULT=DROP injects that fault part way through the dump +# RESTART=1 discards any saved progress and starts over +require 'openc3/utilities/crc' + +START_OFFSET = 0 +TOTAL_SIZE = 10000 # Bytes to dump +CHUNK_SIZE = 1024 # Bytes per MEMDUMP command (max 4096) +MAX_ATTEMPTS = 3 # Times to request a chunk before giving up (bad CRC or no response) +RESPONSE_TIMEOUT = 5 # Seconds to wait for each MEMDUMP response +LINK_TIMEOUT = 60 # Seconds to wait for a lost link to come back +CHECKPOINT_CHUNKS = 2 # Save progress to the bucket every N chunks +PROGRESS_KEY = 'INST_MEMDUMP_PROGRESS' # Stash key holding the checkpoint + +crc = OpenC3::Crc32.new +fault = ENV['FAULT'] +end_offset = START_OFFSET + TOTAL_SIZE +dump_data = ''.b # The verified dump is small enough to keep in memory +offset = START_OFFSET +filename = nil +checkpointed = false # Whether a partial file exists in the bucket +progress = stash_get(PROGRESS_KEY) +if progress + data = nil + # Only resume a dump of the same region and only if the checkpoint is intact + if !ENV['RESTART'] and progress['start_offset'] == START_OFFSET and progress['total_size'] == TOTAL_SIZE + partial = get_target_file(progress['partial_path']) + if partial + data = partial.read + partial.delete + end + end + if data and data.length == progress['next_offset'] - START_OFFSET and crc.calc(data) == progress['crc'] + dump_data << data.b + offset = progress['next_offset'] + filename = progress['filename'] + checkpointed = true + puts "Resuming #{filename} at OFFSET #{offset} (#{data.length} bytes already verified)" + else + if ENV['RESTART'] + puts "Discarding saved progress because RESTART is set" + else + puts "WARN: Discarding saved progress which doesn't match this dump" + end + stash_delete(PROGRESS_KEY) + # Remove the old partial file so it doesn't linger in the bucket + begin + delete_target_file(progress['partial_path']) if progress['partial_path'] + rescue => e + puts "WARN: Unable to delete old partial file: #{e.message}" + end + end +end +filename ||= "INST_memdump_#{Time.now.utc.strftime('%Y%m%d%H%M%S')}.bin" +bucket_path = "INST/memdumps/#{filename}" +partial_path = "#{bucket_path}.partial" + +fault_offset = START_OFFSET + (TOTAL_SIZE / CHUNK_SIZE / 2) * CHUNK_SIZE +chunks = 0 +while offset < end_offset + size = [CHUNK_SIZE, end_offset - offset].min + # Inject the requested fault on the chunk in the middle of the dump + # (or the first chunk if resuming past the middle) + if fault and offset >= fault_offset + cmd("INST MEMDUMP_FAULT with TYPE #{fault}") + fault = nil + end + + data = nil + attempt = 0 + while data.nil? and attempt < MAX_ATTEMPTS + attempt += 1 + # Record the received count so we know when the response arrives + count = tlm("INST MEMDUMP RECEIVED_COUNT") || 0 + + # Send the command, waiting for the link if it is down. This doesn't use up + # an attempt. An ack timeout may mean the command did reach the target but + # the duplicate response is harmless because it has the same OFFSET and SIZE. + link_deadline = Time.now + LINK_TIMEOUT + sent = false + until sent + begin + # The command CRC over OFFSET and SIZE is calculated automatically + cmd("INST MEMDUMP with OFFSET #{offset}, SIZE #{size}") + sent = true + rescue => e + raise unless e.message =~ /not connected|waiting for cmd ack/i and Time.now < link_deadline + puts "WARN: #{e.message}, retrying until the link returns" + wait(2) + end + end + + # Wait for the response to this request. A stale response (e.g. a late + # reply to an earlier request) is skipped and we keep waiting. + dump = nil + deadline = Time.now + RESPONSE_TIMEOUT + while (remaining = deadline - Time.now) > 0 + # wait returns false on timeout rather than raising + break unless wait("INST MEMDUMP RECEIVED_COUNT > #{count}", remaining) + # Grab all the items at once so they all come from the same packet + response = get_tlm_packet("INST MEMDUMP", type: :RAW).to_h { |name, value, _| [name, value] } + count = response['RECEIVED_COUNT'] + if response['OFFSET'] == offset and response['SIZE'] == size + dump = response + break + end + puts "WARN: Ignoring stale response for OFFSET #{response['OFFSET']}, SIZE #{response['SIZE']} " \ + "while waiting for OFFSET #{offset}, SIZE #{size}" + end + unless dump + puts "WARN: No response for OFFSET #{offset} (attempt #{attempt} of #{MAX_ATTEMPTS})" + next + end + + # CRC_OK is a derived item comparing the target CRC to the ground CRC + # but we also check it here to show how to calculate a CRC in a script + calculated = crc.calc(dump['DATA']) + if calculated == dump['CRC'] and dump['CRC_OK'] == 1 + data = dump['DATA'] + else + puts "WARN: CRC mismatch at OFFSET #{offset}: target 0x#{dump['CRC'].to_s(16).upcase} vs " \ + "calculated 0x#{calculated.to_s(16).upcase} (attempt #{attempt} of #{MAX_ATTEMPTS})" + end + end + raise "Unable to dump OFFSET #{offset} after #{MAX_ATTEMPTS} attempts" unless data + dump_data << data.b + offset += size + + # Checkpoint the verified data so an interrupted dump can resume here + chunks += 1 + if chunks % CHECKPOINT_CHUNKS == 0 and offset < end_offset + put_target_file(partial_path, dump_data) + stash_set(PROGRESS_KEY, { 'filename' => filename, 'partial_path' => partial_path, 'start_offset' => START_OFFSET, + 'total_size' => TOTAL_SIZE, 'next_offset' => offset, 'crc' => crc.calc(dump_data) }) + checkpointed = true + puts "Checkpoint saved at OFFSET #{offset}" + end +end +puts "Dumped #{dump_data.length} bytes (CRC32 0x#{crc.calc(dump_data).to_s(16).upcase})" + +# Push the dump to the bucket and clean up the checkpoint +put_target_file(bucket_path, dump_data) +stash_delete(PROGRESS_KEY) +delete_target_file(partial_path) if checkpointed + +# Read it back to verify it made it to the bucket intact +file = get_target_file(bucket_path) +data = file.read +file.delete +check_expression("#{data.length} == #{TOTAL_SIZE}") +check_expression("#{crc.calc(data)} == #{crc.calc(Array.new(TOTAL_SIZE) { |i| (START_OFFSET + i) & 0xFF }.pack('C*'))}") +puts "Memory dump saved to #{bucket_path}" diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt new file mode 100644 index 0000000000..375f6b941d --- /dev/null +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt @@ -0,0 +1,44 @@ +SCREEN AUTO AUTO 1.0 + +TITLE "<%= target_name %> Memory Dump" + +HORIZONTAL + VERTICALBOX "Commands" + MATRIXBYCOLUMNS 2 + LABEL "Offset:" + NAMED_WIDGET OFFSET TEXTFIELD 10 "0" + LABEL "Size:" + NAMED_WIDGET SIZE TEXTFIELD 10 "256" + END + # The command CRC over OFFSET and SIZE is calculated automatically + BUTTON 'Dump Memory' "var offset=screen.getNamedWidget('OFFSET').text();" + + "var size=screen.getNamedWidget('SIZE').text();" + + "api.cmd('<%= target_name %> MEMDUMP with OFFSET '+offset+', SIZE '+size)" + # Flips a random bit in the next dump after the target calculates its CRC + BUTTON 'Flip Bit in Next Dump' "api.cmd('<%= target_name %> MEMDUMP_FAULT with TYPE BITFLIP')" + # Discards the next dump response so it never arrives + BUTTON 'Drop Next Reply' "api.cmd('<%= target_name %> MEMDUMP_FAULT with TYPE DROP')" + # cmd_raw skips the CRC write conversion so the target rejects the command + BUTTON 'Send Bad Command CRC' "api.cmd_raw('<%= target_name %> MEMDUMP with OFFSET 0, SIZE 16, CRC 0')" + # The script resumes from its last checkpoint if a previous run was interrupted + BUTTON 'Run Dump Script' "runScript('<%= target_name %>/procedures/memdump.rb', true)" + BUTTON 'Run Dump Script with Bit Flip' "runScript('<%= target_name %>/procedures/memdump.rb', true, {'FAULT': 'BITFLIP'})" + BUTTON 'Run Dump Script with Dropped Reply' "runScript('<%= target_name %>/procedures/memdump.rb', true, {'FAULT': 'DROP'})" + END + + VERTICALBOX "Last Dump" + LABELVALUE <%= target_name %> MEMDUMP OFFSET + LABELVALUE <%= target_name %> MEMDUMP SIZE + LABELVALUE <%= target_name %> MEMDUMP CRC + LABELVALUE <%= target_name %> MEMDUMP CALC_CRC + LABELLED <%= target_name %> MEMDUMP CRC_OK + SETTING LED_COLOR TRUE GREEN + SETTING LED_COLOR FALSE RED + LABELVALUE <%= target_name %> MEMDUMP RECEIVED_COUNT + LABELVALUE <%= target_name %> MEMDUMP PACKET_TIME + END +END + +VERTICALBOX "Data" + BLOCK <%= target_name %> MEMDUMP DATA 680 200 "%02X" 4 8 "0x%08X: " +END diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/cmd_tlm/inst_cmds.txt b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/cmd_tlm/inst_cmds.txt index 8821e300cd..233d7410fd 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/cmd_tlm/inst_cmds.txt +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/cmd_tlm/inst_cmds.txt @@ -279,6 +279,24 @@ COMMAND <%= target_name %> BLOCK BIG_ENDIAN "Send variable block of data" HIDDEN RELATED_ITEM <%= target_name %> HEALTH_STATUS BLOCKTEST +COMMAND <%= target_name %> MEMDUMP BIG_ENDIAN "Dump a block of simulated memory" + <%= render "_ccsds_cmd.txt", locals: {id: 17} %> + APPEND_PARAMETER OFFSET 32 UINT MIN MAX 0 "Memory offset in bytes to start the dump" + APPEND_PARAMETER SIZE 32 UINT 1 4096 1024 "Number of bytes to dump" + UNITS Bytes B + APPEND_PARAMETER CRC 32 UINT MIN MAX 0 "CRC32 over OFFSET and SIZE (calculated automatically, use cmd_raw to override)" + FORMAT_STRING "0x%08X" + WRITE_CONVERSION memdump_cmd_crc_conversion.py + RELATED_ITEM <%= target_name %> MEMDUMP DATA + +COMMAND <%= target_name %> MEMDUMP_FAULT BIG_ENDIAN "Inject a fault into the next MEMDUMP response to simulate a bad downlink" + VALIDATOR inst2_cmd_validator.py + <%= render "_ccsds_cmd.txt", locals: {id: 18} %> + APPEND_PARAMETER TYPE 8 UINT 0 1 0 "Type of fault to inject" + STATE BITFLIP 0 # Flip a random bit in the data after the CRC is calculated + STATE DROP 1 # Discard the response so it never arrives + RELATED_ITEM <%= target_name %> MEMDUMP CRC_OK + # Throughput Testing Commands (for use with external throughput server) COMMAND <%= target_name %> START_STREAM BIG_ENDIAN "Start telemetry streaming from throughput server" <%= render "_ccsds_cmd.txt", locals: {id: 200} %> diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/cmd_tlm/inst_tlm.txt b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/cmd_tlm/inst_tlm.txt index 6206f78d7c..8d42206180 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/cmd_tlm/inst_tlm.txt +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/cmd_tlm/inst_tlm.txt @@ -208,6 +208,30 @@ TELEMETRY <%= target_name %> VARIABLE_ARRAYS BIG_ENDIAN "Variable length arrays" READ_CONVERSION openc3/conversions/unix_time_conversion.py TIMESEC TIMEUS CONVERTED_DATA 280 STRING +TELEMETRY <%= target_name %> MEMDUMP BIG_ENDIAN "Memory dump sent in response to the MEMDUMP command" + <%= render "_ccsds_tlm.txt", locals: {apid: 8} %> + APPEND_ITEM OFFSET 32 UINT "Memory offset in bytes of the dump" + APPEND_ITEM SIZE 32 UINT "Number of bytes in the dump" + UNITS Bytes B + APPEND_ITEM DATA 0 BLOCK "Dumped memory" + VARIABLE_BIT_SIZE SIZE + APPEND_ITEM CRC 32 UINT "CRC32 of DATA calculated by the target" + FORMAT_STRING "0x%08X" + ITEM CALC_CRC 0 0 DERIVED "CRC32 of DATA calculated on the ground" + READ_CONVERSION crc32_conversion.py DATA + CONVERTED_DATA 32 UINT + FORMAT_STRING "0x%08X" + ITEM CRC_OK 0 0 DERIVED "Whether the target CRC matches the ground CRC" + GENERIC_READ_CONVERSION_START + 1 if packet.read('CRC') == packet.read('CALC_CRC') else 0 + GENERIC_READ_CONVERSION_END + CONVERTED_DATA 8 UINT + STATE TRUE 1 GREEN + STATE FALSE 0 RED + ITEM PACKET_TIME 0 0 DERIVED "Python time based on TIMESEC and TIMEUS" + READ_CONVERSION openc3/conversions/unix_time_conversion.py TIMESEC TIMEUS + CONVERTED_DATA 280 STRING + TELEMETRY <%= target_name %> JSONTLM BIG_ENDIAN "JSON Accessor Telemetry" ACCESSOR JsonAccessor # Template is not required for telemetry, but is useful for simulation diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/crc32_conversion.py b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/crc32_conversion.py new file mode 100644 index 0000000000..0c73e44f85 --- /dev/null +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/crc32_conversion.py @@ -0,0 +1,32 @@ +# Copyright 2026 OpenC3, Inc. +# All Rights Reserved. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See LICENSE.md for more details. +# +# This file may also be used under the terms of a commercial license +# if purchased from OpenC3, Inc. + +from openc3.conversions.conversion import Conversion +from openc3.utilities.crc import Crc32 + + +# Read conversion which calculates the CRC32 of another item in the packet. +# Used to verify the CRC sent by the target. +# See https://docs.openc3.com/docs/configuration/conversions +class Crc32Conversion(Conversion): + # @param item_name [String] Name of the item to calculate the CRC over + def __init__(self, item_name): + super().__init__() + self.item_name = item_name.upper() + self.crc = Crc32() + self.params = [self.item_name] + + # @param value [Object] Value based on the item definition. This could be + # a string, integer, float, or array of values. + # @param packet [Packet] The packet object where the conversion is defined + # @param buffer [String] The raw packet buffer + def call(self, value, packet, buffer): + return self.crc.calc(packet.read(self.item_name, "RAW", buffer)) diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/memdump_cmd_crc_conversion.py b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/memdump_cmd_crc_conversion.py new file mode 100644 index 0000000000..dd82b47323 --- /dev/null +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/memdump_cmd_crc_conversion.py @@ -0,0 +1,37 @@ +# Copyright 2026 OpenC3, Inc. +# All Rights Reserved. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See LICENSE.md for more details. +# +# This file may also be used under the terms of a commercial license +# if purchased from OpenC3, Inc. + +import struct + +from openc3.conversions.conversion import Conversion +from openc3.utilities.crc import Crc32 + + +# Write conversion which calculates the MEMDUMP command CRC32 over the +# big endian OFFSET and SIZE parameters so the user never has to. +# Send the command with cmd_raw to bypass this conversion and set a bad CRC. +# See https://docs.openc3.com/docs/configuration/conversions +class MemdumpCmdCrcConversion(Conversion): + def __init__(self): + super().__init__() + self.crc = Crc32() + + # @param value [Object] Value based on the item definition. This could be + # a string, integer, float, or array of values. + # @param packet [Packet] The packet object where the conversion is defined + # @param buffer [String] The raw packet buffer + def call(self, value, packet, buffer): + # Write conversions run before the given parameters are written to the + # buffer so use the given values and fall back to the defaults + given = packet.given_values or {} + offset = int(given.get("OFFSET", packet.get_item("OFFSET").default)) + size = int(given.get("SIZE", packet.get_item("SIZE").default)) + return self.crc.calc(struct.pack(">II", offset, size)) diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py index 0dcab4db26..0a7c09c1f4 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py @@ -13,6 +13,7 @@ import time import base64 import random +import struct import threading from io import BytesIO from openc3.utilities.simulated_target import SimulatedTarget @@ -21,6 +22,7 @@ from openc3.system.system import System from openc3.top_level import kill_thread from openc3.accessors.binary_accessor import BinaryAccessor +from openc3.utilities.crc import Crc32 # Simulated instrument for the demo. Populates several packets and cycles @@ -124,6 +126,9 @@ def __init__(self, target_name): packet.write("CcsdsSeqFlags", "NOGROUP") packet.write("CcsdsLength", len(packet.buffer) - 7) + packet = self.tlm_packets["MEMDUMP"] + packet.write("CcsdsSeqFlags", "NOGROUP") + self.solar_panel_positions = SimInst.SOLAR_PANEL_DFLTS[:] self.solar_panel_thread = None self.solar_panel_thread_cancel = False @@ -146,6 +151,9 @@ def __init__(self, target_name): self.time_offset = 0 self.ip_address = 0 self.variable_arrays_updated = False + self.memdump_pending = False + self.memdump_fault = None + self.crc = Crc32() def set_rates(self): self.set_rate("ADCS", 10) @@ -244,6 +252,39 @@ def write(self, packet): # Write only arrays - variable_bit_size will auto-update length fields arrays_packet.write("array1", packet.read("ARRAY1")) arrays_packet.write("array2", packet.read("ARRAY2")) + case "MEMDUMP": + offset = packet.read("OFFSET") + size = packet.read("SIZE") + # Reject the command if the CRC over OFFSET and SIZE doesn't match + if packet.read("CRC") != self.crc.calc(struct.pack(">II", offset, size)): + return + # Reject sizes outside the command's range since cmd_raw skips range checks + if not 1 <= size <= 4096: + return + hs_packet.write("cmd_acpt_cnt", hs_packet.read("cmd_acpt_cnt") + 1) + # Simulated memory is a byte counter where each byte holds its address + data = bytes((offset + i) & 0xFF for i in range(size)) + memdump_packet = self.tlm_packets["MEMDUMP"] + memdump_packet.write("OFFSET", offset) + # Writing DATA resizes the packet and updates SIZE due to VARIABLE_BIT_SIZE + memdump_packet.write("DATA", data) + memdump_packet.write("CRC", self.crc.calc(data)) + fault = self.memdump_fault + self.memdump_fault = None + match fault: + case "BITFLIP": + # Flip a random bit after calculating the CRC to simulate downlink corruption + bit = random.randrange(size * 8) + corrupt = bytearray(data) + corrupt[bit // 8] ^= 0x80 >> (bit % 8) + memdump_packet.write("DATA", bytes(corrupt)) + case "DROP": + # The command was accepted but the response is lost on the downlink + return + self.memdump_pending = True + case "MEMDUMP_FAULT": + hs_packet.write("cmd_acpt_cnt", hs_packet.read("cmd_acpt_cnt") + 1) + self.memdump_fault = packet.read("TYPE") def solar_panel_thread_method(self): self.solar_panel_thread_cancel = False @@ -467,4 +508,13 @@ def read(self, count_100hz, time): arrays_packet.write("ccsdsseqcnt", arrays_packet.read("ccsdsseqcnt") + 1) pending_packets.append(arrays_packet) + if self.memdump_pending: + self.memdump_pending = False + memdump_packet = self.tlm_packets["MEMDUMP"] + memdump_packet.write("ccsdslength", len(memdump_packet.buffer) - 7) + memdump_packet.write("timesec", int(time - self.time_offset)) + memdump_packet.write("timeus", int((time % 1) * 1000000)) + memdump_packet.write("ccsdsseqcnt", memdump_packet.read("ccsdsseqcnt") + 1) + pending_packets.append(memdump_packet) + return pending_packets diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/memdump.py b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/memdump.py new file mode 100644 index 0000000000..99d38bbee2 --- /dev/null +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/memdump.py @@ -0,0 +1,186 @@ +# Copyright 2026 OpenC3, Inc. +# All Rights Reserved. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See LICENSE.md for more details. +# +# This file may also be used under the terms of a commercial license +# if purchased from OpenC3, Inc. + +# Dump a region of INST2 memory with the MEMDUMP command, verify each chunk +# and then push the dump to the bucket with put_target_file. +# +# The dump recovers from a bad downlink: +# * Chunks with a bad CRC or no response are requested again +# * If the link is down the script waits for it to come back +# * Progress is checkpointed to the bucket so an interrupted dump resumes +# where it left off the next time the script runs +# +# Environment variables: +# FAULT=BITFLIP or FAULT=DROP injects that fault part way through the dump +# RESTART=1 discards any saved progress and starts over +import os +import re +import time +from datetime import UTC, datetime + +from openc3.utilities.crc import Crc32 + + +START_OFFSET = 0 +TOTAL_SIZE = 10000 # Bytes to dump +CHUNK_SIZE = 1024 # Bytes per MEMDUMP command (max 4096) +MAX_ATTEMPTS = 3 # Times to request a chunk before giving up (bad CRC or no response) +RESPONSE_TIMEOUT = 5 # Seconds to wait for each MEMDUMP response +LINK_TIMEOUT = 60 # Seconds to wait for a lost link to come back +CHECKPOINT_CHUNKS = 2 # Save progress to the bucket every N chunks +PROGRESS_KEY = "INST2_MEMDUMP_PROGRESS" # Stash key holding the checkpoint + +crc = Crc32() +fault = os.environ.get("FAULT") +restart = os.environ.get("RESTART") +end_offset = START_OFFSET + TOTAL_SIZE +dump_data = bytearray() # The verified dump is small enough to keep in memory +offset = START_OFFSET +filename = None +checkpointed = False # Whether a partial file exists in the bucket +progress = stash_get(PROGRESS_KEY) +if progress: + data = None + # Only resume a dump of the same region and only if the checkpoint is intact + if not restart and progress["start_offset"] == START_OFFSET and progress["total_size"] == TOTAL_SIZE: + partial = get_target_file(progress["partial_path"]) + if partial: + data = partial.read() + partial.close() + if data is not None and len(data) == progress["next_offset"] - START_OFFSET and crc.calc(data) == progress["crc"]: + dump_data += data + offset = progress["next_offset"] + filename = progress["filename"] + checkpointed = True + print(f"Resuming {filename} at OFFSET {offset} ({len(data)} bytes already verified)") + else: + if restart: + print("Discarding saved progress because RESTART is set") + else: + print("WARN: Discarding saved progress which doesn't match this dump") + stash_delete(PROGRESS_KEY) + # Remove the old partial file so it doesn't linger in the bucket + if progress.get("partial_path"): + try: + delete_target_file(progress["partial_path"]) + except Exception as error: + print(f"WARN: Unable to delete old partial file: {error}") +if filename is None: + filename = f"INST2_memdump_{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}.bin" +bucket_path = f"INST2/memdumps/{filename}" +partial_path = f"{bucket_path}.partial" + +fault_offset = START_OFFSET + (TOTAL_SIZE // CHUNK_SIZE // 2) * CHUNK_SIZE +chunks = 0 +while offset < end_offset: + size = min(CHUNK_SIZE, end_offset - offset) + # Inject the requested fault on the chunk in the middle of the dump + # (or the first chunk if resuming past the middle) + if fault and offset >= fault_offset: + cmd(f"INST2 MEMDUMP_FAULT with TYPE {fault}") + fault = None + + data = None + attempt = 0 + while data is None and attempt < MAX_ATTEMPTS: + attempt += 1 + # Record the received count so we know when the response arrives + count = tlm("INST2 MEMDUMP RECEIVED_COUNT") or 0 + + # Send the command, waiting for the link if it is down. This doesn't use up + # an attempt. An ack timeout may mean the command did reach the target but + # the duplicate response is harmless because it has the same OFFSET and SIZE. + link_deadline = time.time() + LINK_TIMEOUT + sent = False + while not sent: + try: + # The command CRC over OFFSET and SIZE is calculated automatically + cmd(f"INST2 MEMDUMP with OFFSET {offset}, SIZE {size}") + sent = True + except Exception as error: + if not re.search("not connected|waiting for cmd ack", str(error), re.IGNORECASE): + raise + if time.time() > link_deadline: + raise + print(f"WARN: {error}, retrying until the link returns") + wait(2) + + # Wait for the response to this request. A stale response (e.g. a late + # reply to an earlier request) is skipped and we keep waiting. + dump = None + deadline = time.time() + RESPONSE_TIMEOUT + while (remaining := deadline - time.time()) > 0: + # wait returns False on timeout rather than raising + if not wait(f"INST2 MEMDUMP RECEIVED_COUNT > {count}", remaining): + break + # Grab all the items at once so they all come from the same packet + response = {name: value for name, value, _ in get_tlm_packet("INST2 MEMDUMP", type="RAW")} + count = response["RECEIVED_COUNT"] + if response["OFFSET"] == offset and response["SIZE"] == size: + dump = response + break + print( + f"WARN: Ignoring stale response for OFFSET {response['OFFSET']}, SIZE {response['SIZE']} " + f"while waiting for OFFSET {offset}, SIZE {size}" + ) + if dump is None: + print(f"WARN: No response for OFFSET {offset} (attempt {attempt} of {MAX_ATTEMPTS})") + continue + + # CRC_OK is a derived item comparing the target CRC to the ground CRC + # but we also check it here to show how to calculate a CRC in a script + chunk = bytes(dump["DATA"]) + calculated = crc.calc(chunk) + if calculated == dump["CRC"] and dump["CRC_OK"] == 1: + data = chunk + else: + print( + f"WARN: CRC mismatch at OFFSET {offset}: target 0x{dump['CRC']:08X} vs " + f"calculated 0x{calculated:08X} (attempt {attempt} of {MAX_ATTEMPTS})" + ) + if data is None: + raise RuntimeError(f"Unable to dump OFFSET {offset} after {MAX_ATTEMPTS} attempts") + dump_data += data + offset += size + + # Checkpoint the verified data so an interrupted dump can resume here + chunks += 1 + if chunks % CHECKPOINT_CHUNKS == 0 and offset < end_offset: + put_target_file(partial_path, bytes(dump_data)) + stash_set( + PROGRESS_KEY, + { + "filename": filename, + "partial_path": partial_path, + "start_offset": START_OFFSET, + "total_size": TOTAL_SIZE, + "next_offset": offset, + "crc": crc.calc(dump_data), + }, + ) + checkpointed = True + print(f"Checkpoint saved at OFFSET {offset}") +print(f"Dumped {len(dump_data)} bytes (CRC32 0x{crc.calc(dump_data):08X})") + +# Push the dump to the bucket and clean up the checkpoint +put_target_file(bucket_path, bytes(dump_data)) +stash_delete(PROGRESS_KEY) +if checkpointed: + delete_target_file(partial_path) + +# Read it back to verify it made it to the bucket intact +file = get_target_file(bucket_path) +data = file.read() +file.close() +expected = bytes((START_OFFSET + i) & 0xFF for i in range(TOTAL_SIZE)) +check_expression(f"{len(data)} == {TOTAL_SIZE}") +check_expression(f"{crc.calc(data)} == {crc.calc(expected)}") +print(f"Memory dump saved to {bucket_path}") diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt new file mode 100644 index 0000000000..008db0f264 --- /dev/null +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt @@ -0,0 +1,44 @@ +SCREEN AUTO AUTO 1.0 + +TITLE "<%= target_name %> Memory Dump" + +HORIZONTAL + VERTICALBOX "Commands" + MATRIXBYCOLUMNS 2 + LABEL "Offset:" + NAMED_WIDGET OFFSET TEXTFIELD 10 "0" + LABEL "Size:" + NAMED_WIDGET SIZE TEXTFIELD 10 "256" + END + # The command CRC over OFFSET and SIZE is calculated automatically + BUTTON 'Dump Memory' "var offset=screen.getNamedWidget('OFFSET').text();" + + "var size=screen.getNamedWidget('SIZE').text();" + + "api.cmd('<%= target_name %> MEMDUMP with OFFSET '+offset+', SIZE '+size)" + # Flips a random bit in the next dump after the target calculates its CRC + BUTTON 'Flip Bit in Next Dump' "api.cmd('<%= target_name %> MEMDUMP_FAULT with TYPE BITFLIP')" + # Discards the next dump response so it never arrives + BUTTON 'Drop Next Reply' "api.cmd('<%= target_name %> MEMDUMP_FAULT with TYPE DROP')" + # cmd_raw skips the CRC write conversion so the target rejects the command + BUTTON 'Send Bad Command CRC' "api.cmd_raw('<%= target_name %> MEMDUMP with OFFSET 0, SIZE 16, CRC 0')" + # The script resumes from its last checkpoint if a previous run was interrupted + BUTTON 'Run Dump Script' "runScript('<%= target_name %>/procedures/memdump.py', true)" + BUTTON 'Run Dump Script with Bit Flip' "runScript('<%= target_name %>/procedures/memdump.py', true, {'FAULT': 'BITFLIP'})" + BUTTON 'Run Dump Script with Dropped Reply' "runScript('<%= target_name %>/procedures/memdump.py', true, {'FAULT': 'DROP'})" + END + + VERTICALBOX "Last Dump" + LABELVALUE <%= target_name %> MEMDUMP OFFSET + LABELVALUE <%= target_name %> MEMDUMP SIZE + LABELVALUE <%= target_name %> MEMDUMP CRC + LABELVALUE <%= target_name %> MEMDUMP CALC_CRC + LABELLED <%= target_name %> MEMDUMP CRC_OK + SETTING LED_COLOR TRUE GREEN + SETTING LED_COLOR FALSE RED + LABELVALUE <%= target_name %> MEMDUMP RECEIVED_COUNT + LABELVALUE <%= target_name %> MEMDUMP PACKET_TIME + END +END + +VERTICALBOX "Data" + BLOCK <%= target_name %> MEMDUMP DATA 680 200 "%02X" 4 8 "0x%08X: " +END From 519adcb479209dac1592a62e01e53c48022aa18f Mon Sep 17 00:00:00 2001 From: Callie Date: Fri, 23 Oct 2026 12:00:58 -0600 Subject: [PATCH 2/7] feat(demo): merge MEMDUMP_FAULT into MEMDUMP as BAD_CRC optional param --- .../targets/INST/cmd_tlm/inst_cmds.txt | 13 ++---- .../INST/lib/memdump_cmd_crc_conversion.rb | 8 +++- .../targets/INST/lib/sim_inst.rb | 17 ++------ .../targets/INST/procedures/memdump.rb | 40 +++++++++++------ .../targets/INST/screens/memdump.txt | 14 +++--- .../targets/INST2/cmd_tlm/inst_cmds.txt | 13 ++---- .../INST2/lib/memdump_cmd_crc_conversion.py | 8 +++- .../targets/INST2/lib/sim_inst.py | 27 ++++-------- .../targets/INST2/procedures/memdump.py | 43 ++++++++++++------- .../targets/INST2/screens/memdump.txt | 14 +++--- 10 files changed, 99 insertions(+), 98 deletions(-) diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/cmd_tlm/inst_cmds.txt b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/cmd_tlm/inst_cmds.txt index ddc299b959..47e243afaf 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/cmd_tlm/inst_cmds.txt +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/cmd_tlm/inst_cmds.txt @@ -284,19 +284,14 @@ COMMAND <%= target_name %> MEMDUMP BIG_ENDIAN "Dump a block of simulated memory" APPEND_PARAMETER OFFSET 32 UINT MIN MAX 0 "Memory offset in bytes to start the dump" APPEND_PARAMETER SIZE 32 UINT 1 4096 1024 "Number of bytes to dump" UNITS Bytes B - APPEND_PARAMETER CRC 32 UINT MIN MAX 0 "CRC32 over OFFSET and SIZE (calculated automatically, use cmd_raw to override)" + APPEND_PARAMETER BAD_CRC 8 UINT 0 1 0 "Flip a random bit in the response DATA after the CRC is calculated to simulate a bad CRC" + STATE FALSE 0 + STATE TRUE 1 + APPEND_PARAMETER CRC 32 UINT MIN MAX 0 "CRC32 over OFFSET, SIZE and BAD_CRC (calculated automatically, use cmd_raw to override)" FORMAT_STRING "0x%08X" WRITE_CONVERSION memdump_cmd_crc_conversion.rb RELATED_ITEM <%= target_name %> MEMDUMP DATA -COMMAND <%= target_name %> MEMDUMP_FAULT BIG_ENDIAN "Inject a fault into the next MEMDUMP response to simulate a bad downlink" - VALIDATOR inst_cmd_validator.rb - <%= render "_ccsds_cmd.txt", locals: {id: 18} %> - APPEND_PARAMETER TYPE 8 UINT 0 1 0 "Type of fault to inject" - STATE BITFLIP 0 # Flip a random bit in the data after the CRC is calculated - STATE DROP 1 # Discard the response so it never arrives - RELATED_ITEM <%= target_name %> MEMDUMP CRC_OK - # Throughput Testing Commands (for use with external throughput server) COMMAND <%= target_name %> START_STREAM BIG_ENDIAN "Start telemetry streaming from throughput server" <%= render "_ccsds_cmd.txt", locals: {id: 200} %> diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb index 4efb286ba9..f786cca018 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb @@ -16,7 +16,7 @@ module OpenC3 # Write conversion which calculates the MEMDUMP command CRC32 over the - # big endian OFFSET and SIZE parameters so the user never has to. + # big endian OFFSET, SIZE and BAD_CRC parameters so the user never has to. # Send the command with cmd_raw to bypass this conversion and set a bad CRC. # See https://docs.openc3.com/docs/configuration/conversions class MemdumpCmdCrcConversion < Conversion @@ -35,7 +35,11 @@ def call(value, packet, buffer) given = packet.given_values || {} offset = Integer(given['OFFSET'] || packet.get_item('OFFSET').default) size = Integer(given['SIZE'] || packet.get_item('SIZE').default) - @crc.calc([offset, size].pack('NN')) + # BAD_CRC may be given as a state name so convert it to its value + bad_crc_item = packet.get_item('BAD_CRC') + bad_crc = given['BAD_CRC'] || bad_crc_item.default + bad_crc = bad_crc_item.states[bad_crc.to_s.upcase] || bad_crc + @crc.calc([offset, size, Integer(bad_crc)].pack('NNC')) end end end diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/sim_inst.rb b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/sim_inst.rb index d7eb8e38cf..f28cef3512 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/sim_inst.rb +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/sim_inst.rb @@ -156,7 +156,6 @@ def initialize(target_name) @ip_address = 0 @variable_arrays_updated = false @memdump_pending = false - @memdump_fault = nil @crc = Crc32.new end @@ -280,8 +279,9 @@ def write(packet) when 'MEMDUMP' offset = packet.read('OFFSET') size = packet.read('SIZE') - # Reject the command if the CRC over OFFSET and SIZE doesn't match - return unless packet.read('CRC') == @crc.calc([offset, size].pack('NN')) + bad_crc = packet.read('BAD_CRC', :RAW) + # Reject the command if the CRC over OFFSET, SIZE and BAD_CRC doesn't match + return unless packet.read('CRC') == @crc.calc([offset, size, bad_crc].pack('NNC')) # Reject sizes outside the command's range since cmd_raw skips range checks return unless (1..4096).cover?(size) hs_packet.cmd_acpt_cnt += 1 @@ -292,22 +292,13 @@ def write(packet) # Writing DATA resizes the packet and updates SIZE due to VARIABLE_BIT_SIZE memdump_packet.write('DATA', data) memdump_packet.write('CRC', @crc.calc(data)) - fault = @memdump_fault - @memdump_fault = nil - case fault - when 'BITFLIP' + if bad_crc == 1 # Flip a random bit after calculating the CRC to simulate downlink corruption bit = rand(size * 8) data.setbyte(bit / 8, data.getbyte(bit / 8) ^ (0x80 >> (bit % 8))) memdump_packet.write('DATA', data) - when 'DROP' - # The command was accepted but the response is lost on the downlink - return end @memdump_pending = true - when 'MEMDUMP_FAULT' - hs_packet.cmd_acpt_cnt += 1 - @memdump_fault = packet.read('TYPE') end end diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb index b0be3660f2..bcb33c2d0f 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb @@ -13,27 +13,28 @@ # and then push the dump to the bucket with put_target_file. # # The dump recovers from a bad downlink: -# * Chunks with a bad CRC or no response are requested again +# * Chunks with no response are requested again +# * Chunks with a bad CRC prompt the operator to retry or stop the dump # * If the link is down the script waits for it to come back # * Progress is checkpointed to the bucket so an interrupted dump resumes # where it left off the next time the script runs # # Environment variables: -# FAULT=BITFLIP or FAULT=DROP injects that fault part way through the dump +# BAD_CRC=1 flips a bit in one chunk part way through the dump to show recovery from a bad CRC # RESTART=1 discards any saved progress and starts over require 'openc3/utilities/crc' START_OFFSET = 0 TOTAL_SIZE = 10000 # Bytes to dump CHUNK_SIZE = 1024 # Bytes per MEMDUMP command (max 4096) -MAX_ATTEMPTS = 3 # Times to request a chunk before giving up (bad CRC or no response) +MAX_ATTEMPTS = 3 # Times to request a chunk with no response before giving up RESPONSE_TIMEOUT = 5 # Seconds to wait for each MEMDUMP response LINK_TIMEOUT = 60 # Seconds to wait for a lost link to come back CHECKPOINT_CHUNKS = 2 # Save progress to the bucket every N chunks PROGRESS_KEY = 'INST_MEMDUMP_PROGRESS' # Stash key holding the checkpoint crc = OpenC3::Crc32.new -fault = ENV['FAULT'] +bad_crc = ENV['BAD_CRC'] end_offset = START_OFFSET + TOTAL_SIZE dump_data = ''.b # The verified dump is small enough to keep in memory offset = START_OFFSET @@ -75,15 +76,16 @@ bucket_path = "INST/memdumps/#{filename}" partial_path = "#{bucket_path}.partial" -fault_offset = START_OFFSET + (TOTAL_SIZE / CHUNK_SIZE / 2) * CHUNK_SIZE +bad_crc_offset = START_OFFSET + (TOTAL_SIZE / CHUNK_SIZE / 2) * CHUNK_SIZE chunks = 0 while offset < end_offset size = [CHUNK_SIZE, end_offset - offset].min - # Inject the requested fault on the chunk in the middle of the dump - # (or the first chunk if resuming past the middle) - if fault and offset >= fault_offset - cmd("INST MEMDUMP_FAULT with TYPE #{fault}") - fault = nil + # Request a bad CRC on the first request for the chunk in the middle of + # the dump (or the first chunk if resuming past the middle) + chunk_bad_crc = 'FALSE' + if bad_crc and offset >= bad_crc_offset + chunk_bad_crc = 'TRUE' + bad_crc = nil end data = nil @@ -100,9 +102,11 @@ sent = false until sent begin - # The command CRC over OFFSET and SIZE is calculated automatically - cmd("INST MEMDUMP with OFFSET #{offset}, SIZE #{size}") + # The command CRC over OFFSET, SIZE and BAD_CRC is calculated automatically + cmd("INST MEMDUMP with OFFSET #{offset}, SIZE #{size}, BAD_CRC #{chunk_bad_crc}") sent = true + # Only the first request has a bad CRC so the retry succeeds + chunk_bad_crc = 'FALSE' rescue => e raise unless e.message =~ /not connected|waiting for cmd ack/i and Time.now < link_deadline puts "WARN: #{e.message}, retrying until the link returns" @@ -136,10 +140,18 @@ # but we also check it here to show how to calculate a CRC in a script calculated = crc.calc(dump['DATA']) if calculated == dump['CRC'] and dump['CRC_OK'] == 1 + puts "CRC passed at OFFSET #{offset}" data = dump['DATA'] else - puts "WARN: CRC mismatch at OFFSET #{offset}: target 0x#{dump['CRC'].to_s(16).upcase} vs " \ - "calculated 0x#{calculated.to_s(16).upcase} (attempt #{attempt} of #{MAX_ATTEMPTS})" + mismatch = "Target CRC 0x#{dump['CRC'].to_s(16).upcase} vs calculated 0x#{calculated.to_s(16).upcase}" + puts "WARN: CRC mismatch at OFFSET #{offset}: #{mismatch}" + # Let the operator decide whether to request the chunk again + answer = message_box("Bad CRC at OFFSET #{offset}. Retry this chunk?", 'Retry', 'Stop Dump', informative: mismatch) + if answer == 'Stop Dump' + raise "Dump stopped by operator at OFFSET #{offset}. Run again to resume from the last checkpoint." + end + # An operator retry doesn't use up an attempt + attempt -= 1 end end raise "Unable to dump OFFSET #{offset} after #{MAX_ATTEMPTS} attempts" unless data diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt index 375f6b941d..880cec8429 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt @@ -10,20 +10,18 @@ HORIZONTAL LABEL "Size:" NAMED_WIDGET SIZE TEXTFIELD 10 "256" END - # The command CRC over OFFSET and SIZE is calculated automatically + # Flips a random bit in the dump after the target calculates its CRC + NAMED_WIDGET BAD_CRC CHECKBUTTON 'Bad CRC' + # The command CRC over OFFSET, SIZE and BAD_CRC is calculated automatically BUTTON 'Dump Memory' "var offset=screen.getNamedWidget('OFFSET').text();" + "var size=screen.getNamedWidget('SIZE').text();" + - "api.cmd('<%= target_name %> MEMDUMP with OFFSET '+offset+', SIZE '+size)" - # Flips a random bit in the next dump after the target calculates its CRC - BUTTON 'Flip Bit in Next Dump' "api.cmd('<%= target_name %> MEMDUMP_FAULT with TYPE BITFLIP')" - # Discards the next dump response so it never arrives - BUTTON 'Drop Next Reply' "api.cmd('<%= target_name %> MEMDUMP_FAULT with TYPE DROP')" + "var badCrc=screen.getNamedWidget('BAD_CRC').checked() ? 'TRUE' : 'FALSE';" + + "api.cmd('<%= target_name %> MEMDUMP with OFFSET '+offset+', SIZE '+size+', BAD_CRC '+badCrc)" # cmd_raw skips the CRC write conversion so the target rejects the command BUTTON 'Send Bad Command CRC' "api.cmd_raw('<%= target_name %> MEMDUMP with OFFSET 0, SIZE 16, CRC 0')" # The script resumes from its last checkpoint if a previous run was interrupted BUTTON 'Run Dump Script' "runScript('<%= target_name %>/procedures/memdump.rb', true)" - BUTTON 'Run Dump Script with Bit Flip' "runScript('<%= target_name %>/procedures/memdump.rb', true, {'FAULT': 'BITFLIP'})" - BUTTON 'Run Dump Script with Dropped Reply' "runScript('<%= target_name %>/procedures/memdump.rb', true, {'FAULT': 'DROP'})" + BUTTON 'Run Dump Script with Bad CRC' "runScript('<%= target_name %>/procedures/memdump.rb', true, {'BAD_CRC': '1'})" END VERTICALBOX "Last Dump" diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/cmd_tlm/inst_cmds.txt b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/cmd_tlm/inst_cmds.txt index 233d7410fd..ff2f336167 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/cmd_tlm/inst_cmds.txt +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/cmd_tlm/inst_cmds.txt @@ -284,19 +284,14 @@ COMMAND <%= target_name %> MEMDUMP BIG_ENDIAN "Dump a block of simulated memory" APPEND_PARAMETER OFFSET 32 UINT MIN MAX 0 "Memory offset in bytes to start the dump" APPEND_PARAMETER SIZE 32 UINT 1 4096 1024 "Number of bytes to dump" UNITS Bytes B - APPEND_PARAMETER CRC 32 UINT MIN MAX 0 "CRC32 over OFFSET and SIZE (calculated automatically, use cmd_raw to override)" + APPEND_PARAMETER BAD_CRC 8 UINT 0 1 0 "Flip a random bit in the response DATA after the CRC is calculated to simulate a bad CRC" + STATE FALSE 0 + STATE TRUE 1 + APPEND_PARAMETER CRC 32 UINT MIN MAX 0 "CRC32 over OFFSET, SIZE and BAD_CRC (calculated automatically, use cmd_raw to override)" FORMAT_STRING "0x%08X" WRITE_CONVERSION memdump_cmd_crc_conversion.py RELATED_ITEM <%= target_name %> MEMDUMP DATA -COMMAND <%= target_name %> MEMDUMP_FAULT BIG_ENDIAN "Inject a fault into the next MEMDUMP response to simulate a bad downlink" - VALIDATOR inst2_cmd_validator.py - <%= render "_ccsds_cmd.txt", locals: {id: 18} %> - APPEND_PARAMETER TYPE 8 UINT 0 1 0 "Type of fault to inject" - STATE BITFLIP 0 # Flip a random bit in the data after the CRC is calculated - STATE DROP 1 # Discard the response so it never arrives - RELATED_ITEM <%= target_name %> MEMDUMP CRC_OK - # Throughput Testing Commands (for use with external throughput server) COMMAND <%= target_name %> START_STREAM BIG_ENDIAN "Start telemetry streaming from throughput server" <%= render "_ccsds_cmd.txt", locals: {id: 200} %> diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/memdump_cmd_crc_conversion.py b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/memdump_cmd_crc_conversion.py index dd82b47323..6c5e810f5d 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/memdump_cmd_crc_conversion.py +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/memdump_cmd_crc_conversion.py @@ -16,7 +16,7 @@ # Write conversion which calculates the MEMDUMP command CRC32 over the -# big endian OFFSET and SIZE parameters so the user never has to. +# big endian OFFSET, SIZE and BAD_CRC parameters so the user never has to. # Send the command with cmd_raw to bypass this conversion and set a bad CRC. # See https://docs.openc3.com/docs/configuration/conversions class MemdumpCmdCrcConversion(Conversion): @@ -34,4 +34,8 @@ def call(self, value, packet, buffer): given = packet.given_values or {} offset = int(given.get("OFFSET", packet.get_item("OFFSET").default)) size = int(given.get("SIZE", packet.get_item("SIZE").default)) - return self.crc.calc(struct.pack(">II", offset, size)) + # BAD_CRC may be given as a state name so convert it to its value + bad_crc_item = packet.get_item("BAD_CRC") + bad_crc = given.get("BAD_CRC", bad_crc_item.default) + bad_crc = bad_crc_item.states.get(str(bad_crc).upper(), bad_crc) + return self.crc.calc(struct.pack(">IIB", offset, size, int(bad_crc))) diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py index 0a7c09c1f4..d801669675 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py @@ -152,7 +152,6 @@ def __init__(self, target_name): self.ip_address = 0 self.variable_arrays_updated = False self.memdump_pending = False - self.memdump_fault = None self.crc = Crc32() def set_rates(self): @@ -255,8 +254,9 @@ def write(self, packet): case "MEMDUMP": offset = packet.read("OFFSET") size = packet.read("SIZE") - # Reject the command if the CRC over OFFSET and SIZE doesn't match - if packet.read("CRC") != self.crc.calc(struct.pack(">II", offset, size)): + bad_crc = packet.read("BAD_CRC", "RAW") + # Reject the command if the CRC over OFFSET, SIZE and BAD_CRC doesn't match + if packet.read("CRC") != self.crc.calc(struct.pack(">IIB", offset, size, bad_crc)): return # Reject sizes outside the command's range since cmd_raw skips range checks if not 1 <= size <= 4096: @@ -269,22 +269,13 @@ def write(self, packet): # Writing DATA resizes the packet and updates SIZE due to VARIABLE_BIT_SIZE memdump_packet.write("DATA", data) memdump_packet.write("CRC", self.crc.calc(data)) - fault = self.memdump_fault - self.memdump_fault = None - match fault: - case "BITFLIP": - # Flip a random bit after calculating the CRC to simulate downlink corruption - bit = random.randrange(size * 8) - corrupt = bytearray(data) - corrupt[bit // 8] ^= 0x80 >> (bit % 8) - memdump_packet.write("DATA", bytes(corrupt)) - case "DROP": - # The command was accepted but the response is lost on the downlink - return + if bad_crc == 1: + # Flip a random bit after calculating the CRC to simulate downlink corruption + bit = random.randrange(size * 8) + corrupt = bytearray(data) + corrupt[bit // 8] ^= 0x80 >> (bit % 8) + memdump_packet.write("DATA", bytes(corrupt)) self.memdump_pending = True - case "MEMDUMP_FAULT": - hs_packet.write("cmd_acpt_cnt", hs_packet.read("cmd_acpt_cnt") + 1) - self.memdump_fault = packet.read("TYPE") def solar_panel_thread_method(self): self.solar_panel_thread_cancel = False diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/memdump.py b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/memdump.py index 99d38bbee2..1c941544ed 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/memdump.py +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/memdump.py @@ -13,13 +13,14 @@ # and then push the dump to the bucket with put_target_file. # # The dump recovers from a bad downlink: -# * Chunks with a bad CRC or no response are requested again +# * Chunks with no response are requested again +# * Chunks with a bad CRC prompt the operator to retry or stop the dump # * If the link is down the script waits for it to come back # * Progress is checkpointed to the bucket so an interrupted dump resumes # where it left off the next time the script runs # # Environment variables: -# FAULT=BITFLIP or FAULT=DROP injects that fault part way through the dump +# BAD_CRC=1 flips a bit in one chunk part way through the dump to show recovery from a bad CRC # RESTART=1 discards any saved progress and starts over import os import re @@ -32,14 +33,14 @@ START_OFFSET = 0 TOTAL_SIZE = 10000 # Bytes to dump CHUNK_SIZE = 1024 # Bytes per MEMDUMP command (max 4096) -MAX_ATTEMPTS = 3 # Times to request a chunk before giving up (bad CRC or no response) +MAX_ATTEMPTS = 3 # Times to request a chunk with no response before giving up RESPONSE_TIMEOUT = 5 # Seconds to wait for each MEMDUMP response LINK_TIMEOUT = 60 # Seconds to wait for a lost link to come back CHECKPOINT_CHUNKS = 2 # Save progress to the bucket every N chunks PROGRESS_KEY = "INST2_MEMDUMP_PROGRESS" # Stash key holding the checkpoint crc = Crc32() -fault = os.environ.get("FAULT") +bad_crc = os.environ.get("BAD_CRC") restart = os.environ.get("RESTART") end_offset = START_OFFSET + TOTAL_SIZE dump_data = bytearray() # The verified dump is small enough to keep in memory @@ -78,15 +79,16 @@ bucket_path = f"INST2/memdumps/{filename}" partial_path = f"{bucket_path}.partial" -fault_offset = START_OFFSET + (TOTAL_SIZE // CHUNK_SIZE // 2) * CHUNK_SIZE +bad_crc_offset = START_OFFSET + (TOTAL_SIZE // CHUNK_SIZE // 2) * CHUNK_SIZE chunks = 0 while offset < end_offset: size = min(CHUNK_SIZE, end_offset - offset) - # Inject the requested fault on the chunk in the middle of the dump - # (or the first chunk if resuming past the middle) - if fault and offset >= fault_offset: - cmd(f"INST2 MEMDUMP_FAULT with TYPE {fault}") - fault = None + # Request a bad CRC on the first request for the chunk in the middle of + # the dump (or the first chunk if resuming past the middle) + chunk_bad_crc = "FALSE" + if bad_crc and offset >= bad_crc_offset: + chunk_bad_crc = "TRUE" + bad_crc = None data = None attempt = 0 @@ -102,9 +104,11 @@ sent = False while not sent: try: - # The command CRC over OFFSET and SIZE is calculated automatically - cmd(f"INST2 MEMDUMP with OFFSET {offset}, SIZE {size}") + # The command CRC over OFFSET, SIZE and BAD_CRC is calculated automatically + cmd(f"INST2 MEMDUMP with OFFSET {offset}, SIZE {size}, BAD_CRC {chunk_bad_crc}") sent = True + # Only the first request has a bad CRC so the retry succeeds + chunk_bad_crc = "FALSE" except Exception as error: if not re.search("not connected|waiting for cmd ack", str(error), re.IGNORECASE): raise @@ -140,12 +144,21 @@ chunk = bytes(dump["DATA"]) calculated = crc.calc(chunk) if calculated == dump["CRC"] and dump["CRC_OK"] == 1: + print(f"CRC passed at OFFSET {offset}") data = chunk else: - print( - f"WARN: CRC mismatch at OFFSET {offset}: target 0x{dump['CRC']:08X} vs " - f"calculated 0x{calculated:08X} (attempt {attempt} of {MAX_ATTEMPTS})" + mismatch = f"Target CRC 0x{dump['CRC']:08X} vs calculated 0x{calculated:08X}" + print(f"WARN: CRC mismatch at OFFSET {offset}: {mismatch}") + # Let the operator decide whether to request the chunk again + answer = message_box( + f"Bad CRC at OFFSET {offset}. Retry this chunk?", "Retry", "Stop Dump", informative=mismatch ) + if answer == "Stop Dump": + raise RuntimeError( + f"Dump stopped by operator at OFFSET {offset}. Run again to resume from the last checkpoint." + ) + # An operator retry doesn't use up an attempt + attempt -= 1 if data is None: raise RuntimeError(f"Unable to dump OFFSET {offset} after {MAX_ATTEMPTS} attempts") dump_data += data diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt index 008db0f264..b561ba6664 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt @@ -10,20 +10,18 @@ HORIZONTAL LABEL "Size:" NAMED_WIDGET SIZE TEXTFIELD 10 "256" END - # The command CRC over OFFSET and SIZE is calculated automatically + # Flips a random bit in the dump after the target calculates its CRC + NAMED_WIDGET BAD_CRC CHECKBUTTON 'Bad CRC' + # The command CRC over OFFSET, SIZE and BAD_CRC is calculated automatically BUTTON 'Dump Memory' "var offset=screen.getNamedWidget('OFFSET').text();" + "var size=screen.getNamedWidget('SIZE').text();" + - "api.cmd('<%= target_name %> MEMDUMP with OFFSET '+offset+', SIZE '+size)" - # Flips a random bit in the next dump after the target calculates its CRC - BUTTON 'Flip Bit in Next Dump' "api.cmd('<%= target_name %> MEMDUMP_FAULT with TYPE BITFLIP')" - # Discards the next dump response so it never arrives - BUTTON 'Drop Next Reply' "api.cmd('<%= target_name %> MEMDUMP_FAULT with TYPE DROP')" + "var badCrc=screen.getNamedWidget('BAD_CRC').checked() ? 'TRUE' : 'FALSE';" + + "api.cmd('<%= target_name %> MEMDUMP with OFFSET '+offset+', SIZE '+size+', BAD_CRC '+badCrc)" # cmd_raw skips the CRC write conversion so the target rejects the command BUTTON 'Send Bad Command CRC' "api.cmd_raw('<%= target_name %> MEMDUMP with OFFSET 0, SIZE 16, CRC 0')" # The script resumes from its last checkpoint if a previous run was interrupted BUTTON 'Run Dump Script' "runScript('<%= target_name %>/procedures/memdump.py', true)" - BUTTON 'Run Dump Script with Bit Flip' "runScript('<%= target_name %>/procedures/memdump.py', true, {'FAULT': 'BITFLIP'})" - BUTTON 'Run Dump Script with Dropped Reply' "runScript('<%= target_name %>/procedures/memdump.py', true, {'FAULT': 'DROP'})" + BUTTON 'Run Dump Script with Bad CRC' "runScript('<%= target_name %>/procedures/memdump.py', true, {'BAD_CRC': '1'})" END VERTICALBOX "Last Dump" From 4aab7656c4fecbbca14154d2ab51bba085ff36d2 Mon Sep 17 00:00:00 2001 From: Callie Date: Fri, 23 Oct 2026 12:02:19 -0600 Subject: [PATCH 3/7] feat(demo): code cleanup for readability --- .../targets/INST/lib/sim_inst.rb | 24 +++--- .../targets/INST/procedures/memdump.rb | 68 ++-------------- .../targets/INST/screens/memdump.txt | 1 - .../targets/INST2/lib/sim_inst.py | 30 ++++---- .../targets/INST2/procedures/memdump.py | 77 ++----------------- .../targets/INST2/screens/memdump.txt | 1 - 6 files changed, 44 insertions(+), 157 deletions(-) diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/sim_inst.rb b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/sim_inst.rb index f28cef3512..b91329989c 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/sim_inst.rb +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/sim_inst.rb @@ -155,7 +155,7 @@ def initialize(target_name) @time_offset = 0 @ip_address = 0 @variable_arrays_updated = false - @memdump_pending = false + @memdump_response = nil @crc = Crc32.new end @@ -283,22 +283,19 @@ def write(packet) # Reject the command if the CRC over OFFSET, SIZE and BAD_CRC doesn't match return unless packet.read('CRC') == @crc.calc([offset, size, bad_crc].pack('NNC')) # Reject sizes outside the command's range since cmd_raw skips range checks - return unless (1..4096).cover?(size) + return unless packet.get_item('SIZE').range.cover?(size) hs_packet.cmd_acpt_cnt += 1 # Simulated memory is a byte counter where each byte holds its address data = Array.new(size) { |i| (offset + i) & 0xFF }.pack('C*') - memdump_packet = @tlm_packets['MEMDUMP'] - memdump_packet.write('OFFSET', offset) - # Writing DATA resizes the packet and updates SIZE due to VARIABLE_BIT_SIZE - memdump_packet.write('DATA', data) - memdump_packet.write('CRC', @crc.calc(data)) + crc = @crc.calc(data) if bad_crc == 1 # Flip a random bit after calculating the CRC to simulate downlink corruption bit = rand(size * 8) data.setbyte(bit / 8, data.getbyte(bit / 8) ^ (0x80 >> (bit % 8))) - memdump_packet.write('DATA', data) end - @memdump_pending = true + # Commands arrive on a different thread than read so only hand off the + # values here and let read write them into the MEMDUMP packet + @memdump_response = [offset, data, crc] end end @@ -499,9 +496,14 @@ def read(count_100hz, time) pending_packets << arrays_packet end - if @memdump_pending - @memdump_pending = false + memdump_response, @memdump_response = @memdump_response, nil + if memdump_response + offset, data, crc = memdump_response memdump_packet = @tlm_packets['MEMDUMP'] + memdump_packet.write('OFFSET', offset) + # Writing DATA resizes the packet and updates SIZE due to VARIABLE_BIT_SIZE + memdump_packet.write('DATA', data) + memdump_packet.write('CRC', crc) memdump_packet.write('CCSDSLENGTH', memdump_packet.buffer(false).length - 7) memdump_packet.write('TIMESEC', time.tv_sec - @time_offset) memdump_packet.write('TIMEUS', time.tv_usec) diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb index bcb33c2d0f..c0df9473ea 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb @@ -16,12 +16,9 @@ # * Chunks with no response are requested again # * Chunks with a bad CRC prompt the operator to retry or stop the dump # * If the link is down the script waits for it to come back -# * Progress is checkpointed to the bucket so an interrupted dump resumes -# where it left off the next time the script runs # # Environment variables: # BAD_CRC=1 flips a bit in one chunk part way through the dump to show recovery from a bad CRC -# RESTART=1 discards any saved progress and starts over require 'openc3/utilities/crc' START_OFFSET = 0 @@ -30,58 +27,19 @@ MAX_ATTEMPTS = 3 # Times to request a chunk with no response before giving up RESPONSE_TIMEOUT = 5 # Seconds to wait for each MEMDUMP response LINK_TIMEOUT = 60 # Seconds to wait for a lost link to come back -CHECKPOINT_CHUNKS = 2 # Save progress to the bucket every N chunks -PROGRESS_KEY = 'INST_MEMDUMP_PROGRESS' # Stash key holding the checkpoint crc = OpenC3::Crc32.new bad_crc = ENV['BAD_CRC'] end_offset = START_OFFSET + TOTAL_SIZE dump_data = ''.b # The verified dump is small enough to keep in memory offset = START_OFFSET -filename = nil -checkpointed = false # Whether a partial file exists in the bucket -progress = stash_get(PROGRESS_KEY) -if progress - data = nil - # Only resume a dump of the same region and only if the checkpoint is intact - if !ENV['RESTART'] and progress['start_offset'] == START_OFFSET and progress['total_size'] == TOTAL_SIZE - partial = get_target_file(progress['partial_path']) - if partial - data = partial.read - partial.delete - end - end - if data and data.length == progress['next_offset'] - START_OFFSET and crc.calc(data) == progress['crc'] - dump_data << data.b - offset = progress['next_offset'] - filename = progress['filename'] - checkpointed = true - puts "Resuming #{filename} at OFFSET #{offset} (#{data.length} bytes already verified)" - else - if ENV['RESTART'] - puts "Discarding saved progress because RESTART is set" - else - puts "WARN: Discarding saved progress which doesn't match this dump" - end - stash_delete(PROGRESS_KEY) - # Remove the old partial file so it doesn't linger in the bucket - begin - delete_target_file(progress['partial_path']) if progress['partial_path'] - rescue => e - puts "WARN: Unable to delete old partial file: #{e.message}" - end - end -end -filename ||= "INST_memdump_#{Time.now.utc.strftime('%Y%m%d%H%M%S')}.bin" -bucket_path = "INST/memdumps/#{filename}" -partial_path = "#{bucket_path}.partial" +bucket_path = "INST/memdumps/INST_memdump_#{Time.now.utc.strftime('%Y%m%d%H%M%S')}.bin" bad_crc_offset = START_OFFSET + (TOTAL_SIZE / CHUNK_SIZE / 2) * CHUNK_SIZE -chunks = 0 while offset < end_offset size = [CHUNK_SIZE, end_offset - offset].min # Request a bad CRC on the first request for the chunk in the middle of - # the dump (or the first chunk if resuming past the middle) + # the dump chunk_bad_crc = 'FALSE' if bad_crc and offset >= bad_crc_offset chunk_bad_crc = 'TRUE' @@ -136,10 +94,10 @@ next end - # CRC_OK is a derived item comparing the target CRC to the ground CRC - # but we also check it here to show how to calculate a CRC in a script + # Calculate the CRC in the script (the CRC_OK derived item does the same + # check on the ground for display on the screen) calculated = crc.calc(dump['DATA']) - if calculated == dump['CRC'] and dump['CRC_OK'] == 1 + if calculated == dump['CRC'] puts "CRC passed at OFFSET #{offset}" data = dump['DATA'] else @@ -148,7 +106,7 @@ # Let the operator decide whether to request the chunk again answer = message_box("Bad CRC at OFFSET #{offset}. Retry this chunk?", 'Retry', 'Stop Dump', informative: mismatch) if answer == 'Stop Dump' - raise "Dump stopped by operator at OFFSET #{offset}. Run again to resume from the last checkpoint." + raise "Dump stopped by operator at OFFSET #{offset}" end # An operator retry doesn't use up an attempt attempt -= 1 @@ -157,23 +115,11 @@ raise "Unable to dump OFFSET #{offset} after #{MAX_ATTEMPTS} attempts" unless data dump_data << data.b offset += size - - # Checkpoint the verified data so an interrupted dump can resume here - chunks += 1 - if chunks % CHECKPOINT_CHUNKS == 0 and offset < end_offset - put_target_file(partial_path, dump_data) - stash_set(PROGRESS_KEY, { 'filename' => filename, 'partial_path' => partial_path, 'start_offset' => START_OFFSET, - 'total_size' => TOTAL_SIZE, 'next_offset' => offset, 'crc' => crc.calc(dump_data) }) - checkpointed = true - puts "Checkpoint saved at OFFSET #{offset}" - end end puts "Dumped #{dump_data.length} bytes (CRC32 0x#{crc.calc(dump_data).to_s(16).upcase})" -# Push the dump to the bucket and clean up the checkpoint +# Push the dump to the bucket put_target_file(bucket_path, dump_data) -stash_delete(PROGRESS_KEY) -delete_target_file(partial_path) if checkpointed # Read it back to verify it made it to the bucket intact file = get_target_file(bucket_path) diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt index 880cec8429..5e485bf1e4 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt @@ -19,7 +19,6 @@ HORIZONTAL "api.cmd('<%= target_name %> MEMDUMP with OFFSET '+offset+', SIZE '+size+', BAD_CRC '+badCrc)" # cmd_raw skips the CRC write conversion so the target rejects the command BUTTON 'Send Bad Command CRC' "api.cmd_raw('<%= target_name %> MEMDUMP with OFFSET 0, SIZE 16, CRC 0')" - # The script resumes from its last checkpoint if a previous run was interrupted BUTTON 'Run Dump Script' "runScript('<%= target_name %>/procedures/memdump.rb', true)" BUTTON 'Run Dump Script with Bad CRC' "runScript('<%= target_name %>/procedures/memdump.rb', true, {'BAD_CRC': '1'})" END diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py index d801669675..8eb9d2507e 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py @@ -151,7 +151,7 @@ def __init__(self, target_name): self.time_offset = 0 self.ip_address = 0 self.variable_arrays_updated = False - self.memdump_pending = False + self.memdump_response = None self.crc = Crc32() def set_rates(self): @@ -259,23 +259,20 @@ def write(self, packet): if packet.read("CRC") != self.crc.calc(struct.pack(">IIB", offset, size, bad_crc)): return # Reject sizes outside the command's range since cmd_raw skips range checks - if not 1 <= size <= 4096: + size_item = packet.get_item("SIZE") + if not size_item.minimum <= size <= size_item.maximum: return hs_packet.write("cmd_acpt_cnt", hs_packet.read("cmd_acpt_cnt") + 1) # Simulated memory is a byte counter where each byte holds its address - data = bytes((offset + i) & 0xFF for i in range(size)) - memdump_packet = self.tlm_packets["MEMDUMP"] - memdump_packet.write("OFFSET", offset) - # Writing DATA resizes the packet and updates SIZE due to VARIABLE_BIT_SIZE - memdump_packet.write("DATA", data) - memdump_packet.write("CRC", self.crc.calc(data)) + data = bytearray((offset + i) & 0xFF for i in range(size)) + crc = self.crc.calc(data) if bad_crc == 1: # Flip a random bit after calculating the CRC to simulate downlink corruption bit = random.randrange(size * 8) - corrupt = bytearray(data) - corrupt[bit // 8] ^= 0x80 >> (bit % 8) - memdump_packet.write("DATA", bytes(corrupt)) - self.memdump_pending = True + data[bit // 8] ^= 0x80 >> (bit % 8) + # Commands arrive on a different thread than read so only hand off the + # values here and let read write them into the MEMDUMP packet + self.memdump_response = (offset, bytes(data), crc) def solar_panel_thread_method(self): self.solar_panel_thread_cancel = False @@ -499,9 +496,14 @@ def read(self, count_100hz, time): arrays_packet.write("ccsdsseqcnt", arrays_packet.read("ccsdsseqcnt") + 1) pending_packets.append(arrays_packet) - if self.memdump_pending: - self.memdump_pending = False + memdump_response, self.memdump_response = self.memdump_response, None + if memdump_response: + offset, data, crc = memdump_response memdump_packet = self.tlm_packets["MEMDUMP"] + memdump_packet.write("OFFSET", offset) + # Writing DATA resizes the packet and updates SIZE due to VARIABLE_BIT_SIZE + memdump_packet.write("DATA", data) + memdump_packet.write("CRC", crc) memdump_packet.write("ccsdslength", len(memdump_packet.buffer) - 7) memdump_packet.write("timesec", int(time - self.time_offset)) memdump_packet.write("timeus", int((time % 1) * 1000000)) diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/memdump.py b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/memdump.py index 1c941544ed..c685bb908d 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/memdump.py +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/memdump.py @@ -16,16 +16,13 @@ # * Chunks with no response are requested again # * Chunks with a bad CRC prompt the operator to retry or stop the dump # * If the link is down the script waits for it to come back -# * Progress is checkpointed to the bucket so an interrupted dump resumes -# where it left off the next time the script runs # # Environment variables: # BAD_CRC=1 flips a bit in one chunk part way through the dump to show recovery from a bad CRC -# RESTART=1 discards any saved progress and starts over import os import re import time -from datetime import UTC, datetime +from datetime import datetime, timezone from openc3.utilities.crc import Crc32 @@ -36,55 +33,19 @@ MAX_ATTEMPTS = 3 # Times to request a chunk with no response before giving up RESPONSE_TIMEOUT = 5 # Seconds to wait for each MEMDUMP response LINK_TIMEOUT = 60 # Seconds to wait for a lost link to come back -CHECKPOINT_CHUNKS = 2 # Save progress to the bucket every N chunks -PROGRESS_KEY = "INST2_MEMDUMP_PROGRESS" # Stash key holding the checkpoint crc = Crc32() bad_crc = os.environ.get("BAD_CRC") -restart = os.environ.get("RESTART") end_offset = START_OFFSET + TOTAL_SIZE dump_data = bytearray() # The verified dump is small enough to keep in memory offset = START_OFFSET -filename = None -checkpointed = False # Whether a partial file exists in the bucket -progress = stash_get(PROGRESS_KEY) -if progress: - data = None - # Only resume a dump of the same region and only if the checkpoint is intact - if not restart and progress["start_offset"] == START_OFFSET and progress["total_size"] == TOTAL_SIZE: - partial = get_target_file(progress["partial_path"]) - if partial: - data = partial.read() - partial.close() - if data is not None and len(data) == progress["next_offset"] - START_OFFSET and crc.calc(data) == progress["crc"]: - dump_data += data - offset = progress["next_offset"] - filename = progress["filename"] - checkpointed = True - print(f"Resuming {filename} at OFFSET {offset} ({len(data)} bytes already verified)") - else: - if restart: - print("Discarding saved progress because RESTART is set") - else: - print("WARN: Discarding saved progress which doesn't match this dump") - stash_delete(PROGRESS_KEY) - # Remove the old partial file so it doesn't linger in the bucket - if progress.get("partial_path"): - try: - delete_target_file(progress["partial_path"]) - except Exception as error: - print(f"WARN: Unable to delete old partial file: {error}") -if filename is None: - filename = f"INST2_memdump_{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}.bin" -bucket_path = f"INST2/memdumps/{filename}" -partial_path = f"{bucket_path}.partial" +bucket_path = f"INST2/memdumps/INST2_memdump_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}.bin" bad_crc_offset = START_OFFSET + (TOTAL_SIZE // CHUNK_SIZE // 2) * CHUNK_SIZE -chunks = 0 while offset < end_offset: size = min(CHUNK_SIZE, end_offset - offset) # Request a bad CRC on the first request for the chunk in the middle of - # the dump (or the first chunk if resuming past the middle) + # the dump chunk_bad_crc = "FALSE" if bad_crc and offset >= bad_crc_offset: chunk_bad_crc = "TRUE" @@ -139,11 +100,11 @@ print(f"WARN: No response for OFFSET {offset} (attempt {attempt} of {MAX_ATTEMPTS})") continue - # CRC_OK is a derived item comparing the target CRC to the ground CRC - # but we also check it here to show how to calculate a CRC in a script + # Calculate the CRC in the script (the CRC_OK derived item does the same + # check on the ground for display on the screen) chunk = bytes(dump["DATA"]) calculated = crc.calc(chunk) - if calculated == dump["CRC"] and dump["CRC_OK"] == 1: + if calculated == dump["CRC"]: print(f"CRC passed at OFFSET {offset}") data = chunk else: @@ -154,9 +115,7 @@ f"Bad CRC at OFFSET {offset}. Retry this chunk?", "Retry", "Stop Dump", informative=mismatch ) if answer == "Stop Dump": - raise RuntimeError( - f"Dump stopped by operator at OFFSET {offset}. Run again to resume from the last checkpoint." - ) + raise RuntimeError(f"Dump stopped by operator at OFFSET {offset}") # An operator retry doesn't use up an attempt attempt -= 1 if data is None: @@ -164,30 +123,10 @@ dump_data += data offset += size - # Checkpoint the verified data so an interrupted dump can resume here - chunks += 1 - if chunks % CHECKPOINT_CHUNKS == 0 and offset < end_offset: - put_target_file(partial_path, bytes(dump_data)) - stash_set( - PROGRESS_KEY, - { - "filename": filename, - "partial_path": partial_path, - "start_offset": START_OFFSET, - "total_size": TOTAL_SIZE, - "next_offset": offset, - "crc": crc.calc(dump_data), - }, - ) - checkpointed = True - print(f"Checkpoint saved at OFFSET {offset}") print(f"Dumped {len(dump_data)} bytes (CRC32 0x{crc.calc(dump_data):08X})") -# Push the dump to the bucket and clean up the checkpoint +# Push the dump to the bucket put_target_file(bucket_path, bytes(dump_data)) -stash_delete(PROGRESS_KEY) -if checkpointed: - delete_target_file(partial_path) # Read it back to verify it made it to the bucket intact file = get_target_file(bucket_path) diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt index b561ba6664..88f738ed53 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt @@ -19,7 +19,6 @@ HORIZONTAL "api.cmd('<%= target_name %> MEMDUMP with OFFSET '+offset+', SIZE '+size+', BAD_CRC '+badCrc)" # cmd_raw skips the CRC write conversion so the target rejects the command BUTTON 'Send Bad Command CRC' "api.cmd_raw('<%= target_name %> MEMDUMP with OFFSET 0, SIZE 16, CRC 0')" - # The script resumes from its last checkpoint if a previous run was interrupted BUTTON 'Run Dump Script' "runScript('<%= target_name %>/procedures/memdump.py', true)" BUTTON 'Run Dump Script with Bad CRC' "runScript('<%= target_name %>/procedures/memdump.py', true, {'BAD_CRC': '1'})" END From 5b54b1f23a3a93750d5dd1db98c1b30b617cb619 Mon Sep 17 00:00:00 2001 From: Callie Date: Fri, 23 Oct 2026 12:02:45 -0600 Subject: [PATCH 4/7] feat(demo): fix key casing --- .../targets/INST/lib/memdump_cmd_crc_conversion.rb | 5 +++-- .../targets/INST2/lib/memdump_cmd_crc_conversion.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb index f786cca018..0017347557 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb @@ -31,8 +31,9 @@ def initialize # @param buffer [String] The raw packet buffer def call(value, packet, buffer) # Write conversions run before the given parameters are written to the - # buffer so use the given values and fall back to the defaults - given = packet.given_values || {} + # buffer so use the given values and fall back to the defaults. + # Given names keep the caller's case so upcase them to match the items. + given = (packet.given_values || {}).transform_keys { |key| key.to_s.upcase } offset = Integer(given['OFFSET'] || packet.get_item('OFFSET').default) size = Integer(given['SIZE'] || packet.get_item('SIZE').default) # BAD_CRC may be given as a state name so convert it to its value diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/memdump_cmd_crc_conversion.py b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/memdump_cmd_crc_conversion.py index 6c5e810f5d..502856e9c8 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/memdump_cmd_crc_conversion.py +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/memdump_cmd_crc_conversion.py @@ -30,8 +30,9 @@ def __init__(self): # @param buffer [String] The raw packet buffer def call(self, value, packet, buffer): # Write conversions run before the given parameters are written to the - # buffer so use the given values and fall back to the defaults - given = packet.given_values or {} + # buffer so use the given values and fall back to the defaults. + # Given names keep the caller's case so upcase them to match the items. + given = {str(key).upper(): value for key, value in (packet.given_values or {}).items()} offset = int(given.get("OFFSET", packet.get_item("OFFSET").default)) size = int(given.get("SIZE", packet.get_item("SIZE").default)) # BAD_CRC may be given as a state name so convert it to its value From 8741566885827b6b0c59ca7cb8b4dccf527a48a8 Mon Sep 17 00:00:00 2001 From: Callie Date: Fri, 23 Oct 2026 13:24:47 -0600 Subject: [PATCH 5/7] fix(demo): address sonarqube issues --- .../openc3-cosmos-demo/targets/INST/lib/crc32_conversion.rb | 2 +- .../targets/INST/lib/memdump_cmd_crc_conversion.rb | 2 +- .../openc3-cosmos-demo/targets/INST/procedures/memdump.rb | 2 +- .../packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/crc32_conversion.rb b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/crc32_conversion.rb index 32be1b1455..8d42c2e376 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/crc32_conversion.rb +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/crc32_conversion.rb @@ -31,7 +31,7 @@ def initialize(item_name) # a string, integer, float, or array of values. # @param packet [Packet] The packet object where the conversion is defined # @param buffer [String] The raw packet buffer - def call(value, packet, buffer) + def call(_value, packet, buffer) @crc.calc(packet.read(@item_name, :RAW, buffer)) end end diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb index 0017347557..e169a2417c 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb @@ -29,7 +29,7 @@ def initialize # a string, integer, float, or array of values. # @param packet [Packet] The packet object where the conversion is defined # @param buffer [String] The raw packet buffer - def call(value, packet, buffer) + def call(_value, packet, _buffer) # Write conversions run before the given parameters are written to the # buffer so use the given values and fall back to the defaults. # Given names keep the caller's case so upcase them to match the items. diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb index c0df9473ea..3eb4baa0fb 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb @@ -29,7 +29,7 @@ LINK_TIMEOUT = 60 # Seconds to wait for a lost link to come back crc = OpenC3::Crc32.new -bad_crc = ENV['BAD_CRC'] +bad_crc = ENV.fetch('BAD_CRC', nil) end_offset = START_OFFSET + TOTAL_SIZE dump_data = ''.b # The verified dump is small enough to keep in memory offset = START_OFFSET diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py index 8eb9d2507e..3d3b4a1f9a 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/sim_inst.py @@ -268,7 +268,7 @@ def write(self, packet): crc = self.crc.calc(data) if bad_crc == 1: # Flip a random bit after calculating the CRC to simulate downlink corruption - bit = random.randrange(size * 8) + bit = random.randrange(size * 8) # NOSONAR - simulated bit error, not security related data[bit // 8] ^= 0x80 >> (bit % 8) # Commands arrive on a different thread than read so only hand off the # values here and let read write them into the MEMDUMP packet From f4b77fe9d14b4a7c865e292bb18f39b083b90e9c Mon Sep 17 00:00:00 2001 From: Callie Date: Fri, 23 Oct 2026 13:47:54 -0600 Subject: [PATCH 6/7] fix(screen): remove redundant button --- .../packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt | 1 - .../openc3-cosmos-demo/targets/INST2/screens/memdump.txt | 1 - 2 files changed, 2 deletions(-) diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt index 5e485bf1e4..beb78a42e6 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt @@ -18,7 +18,6 @@ HORIZONTAL "var badCrc=screen.getNamedWidget('BAD_CRC').checked() ? 'TRUE' : 'FALSE';" + "api.cmd('<%= target_name %> MEMDUMP with OFFSET '+offset+', SIZE '+size+', BAD_CRC '+badCrc)" # cmd_raw skips the CRC write conversion so the target rejects the command - BUTTON 'Send Bad Command CRC' "api.cmd_raw('<%= target_name %> MEMDUMP with OFFSET 0, SIZE 16, CRC 0')" BUTTON 'Run Dump Script' "runScript('<%= target_name %>/procedures/memdump.rb', true)" BUTTON 'Run Dump Script with Bad CRC' "runScript('<%= target_name %>/procedures/memdump.rb', true, {'BAD_CRC': '1'})" END diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt index 88f738ed53..960150c696 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt @@ -18,7 +18,6 @@ HORIZONTAL "var badCrc=screen.getNamedWidget('BAD_CRC').checked() ? 'TRUE' : 'FALSE';" + "api.cmd('<%= target_name %> MEMDUMP with OFFSET '+offset+', SIZE '+size+', BAD_CRC '+badCrc)" # cmd_raw skips the CRC write conversion so the target rejects the command - BUTTON 'Send Bad Command CRC' "api.cmd_raw('<%= target_name %> MEMDUMP with OFFSET 0, SIZE 16, CRC 0')" BUTTON 'Run Dump Script' "runScript('<%= target_name %>/procedures/memdump.py', true)" BUTTON 'Run Dump Script with Bad CRC' "runScript('<%= target_name %>/procedures/memdump.py', true, {'BAD_CRC': '1'})" END From bd4488cb128c987d47affd5affef31d98a413627 Mon Sep 17 00:00:00 2001 From: Callie Date: Mon, 26 Oct 2026 11:51:58 -0600 Subject: [PATCH 7/7] test(memdump): add playwright test for MEMDUMP and fix failing tests --- .../tests/cmd-tlm-server/tlm-pkts.p.spec.ts | 2 ++ playwright/tests/data-extractor.p.spec.ts | 2 +- playwright/tests/tlm-viewer.p.spec.ts | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/playwright/tests/cmd-tlm-server/tlm-pkts.p.spec.ts b/playwright/tests/cmd-tlm-server/tlm-pkts.p.spec.ts index a41b03cfaf..5b1e44a2a0 100644 --- a/playwright/tests/cmd-tlm-server/tlm-pkts.p.spec.ts +++ b/playwright/tests/cmd-tlm-server/tlm-pkts.p.spec.ts @@ -68,6 +68,8 @@ test.fixme('displays raw packets', async ({ page, utils }) => { }) // Open another raw packet to show we can display more than one + // Filter the table since INST PARAMS isn't necessarily on the first page + await page.getByRole('textbox', { name: 'Search' }).first().fill('PARAMS') await page .getByRole('row', { name: 'INST PARAMS' }) .getByRole('button', { name: 'View Raw' }) diff --git a/playwright/tests/data-extractor.p.spec.ts b/playwright/tests/data-extractor.p.spec.ts index f83468163c..6591f0022a 100644 --- a/playwright/tests/data-extractor.p.spec.ts +++ b/playwright/tests/data-extractor.p.spec.ts @@ -172,7 +172,7 @@ test('cancels a process', async ({ page, utils }) => { test('adds an entire target', async ({ page, utils }) => { await utils.addTargetPacketItem('INST') - await expect(page.getByText('1-20 of 279')).toBeVisible() + await expect(page.getByText('1-20 of 301')).toBeVisible() }) test('adds an entire packet', async ({ page, utils }) => { diff --git a/playwright/tests/tlm-viewer.p.spec.ts b/playwright/tests/tlm-viewer.p.spec.ts index 85d8181b08..097b94b9d1 100644 --- a/playwright/tests/tlm-viewer.p.spec.ts +++ b/playwright/tests/tlm-viewer.p.spec.ts @@ -269,6 +269,23 @@ test('displays INST LIMITS', async ({ page, utils }) => { await showScreen(page, utils, 'INST', 'LIMITS') }) +test('displays INST MEMDUMP', async ({ page, utils }) => { + await showScreen(page, utils, 'INST', 'MEMDUMP', true, async function () { + const value = (item: string) => + page + .locator(`[data-test="label"]:text-is("${item}:")`) + .locator('..') + .locator('[data-test="value"] input') + // Dump with the screen defaults of OFFSET 0 and SIZE 256 + await page.getByRole('button', { name: 'Dump Memory' }).click() + await expect(value('SIZE')).toHaveValue(/^256/) + // The ground calculated CRC should match the CRC sent by the target + await expect(value('CRC')).toHaveValue(/^0x[0-9A-F]{8}$/) + const crc = await value('CRC').inputValue() + await expect(value('CALC_CRC')).toHaveValue(crc) + }) +}) + test('displays INST OTHER', async ({ page, utils }) => { await showScreen(page, utils, 'INST', 'OTHER') })