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..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 @@ -279,6 +279,19 @@ 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 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 + # 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..8d42c2e376 --- /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..e169a2417c --- /dev/null +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/lib/memdump_cmd_crc_conversion.rb @@ -0,0 +1,46 @@ +# 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, 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 + 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 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 + 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 317f73cbdd..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 @@ -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,8 @@ def initialize(target_name) @time_offset = 0 @ip_address = 0 @variable_arrays_updated = false + @memdump_response = nil + @crc = Crc32.new end def set_rates @@ -269,6 +276,26 @@ 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') + 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 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*') + 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))) + end + # 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 @@ -469,6 +496,21 @@ def read(count_100hz, time) pending_packets << arrays_packet end + 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) + 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..3eb4baa0fb --- /dev/null +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/memdump.rb @@ -0,0 +1,130 @@ +# 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 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 +# +# Environment variables: +# BAD_CRC=1 flips a bit in one chunk part way through the dump to show recovery from a bad CRC +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 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 + +crc = OpenC3::Crc32.new +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 +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 +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 + chunk_bad_crc = 'FALSE' + if bad_crc and offset >= bad_crc_offset + chunk_bad_crc = 'TRUE' + bad_crc = 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, 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" + 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 + + # 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'] + puts "CRC passed at OFFSET #{offset}" + data = dump['DATA'] + else + 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}" + 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 + dump_data << data.b + offset += size +end +puts "Dumped #{dump_data.length} bytes (CRC32 0x#{crc.calc(dump_data).to_s(16).upcase})" + +# Push the dump to the bucket +put_target_file(bucket_path, dump_data) + +# 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..beb78a42e6 --- /dev/null +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/screens/memdump.txt @@ -0,0 +1,40 @@ +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 + # 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();" + + "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 '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 + + 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..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 @@ -279,6 +279,19 @@ 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 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 + # 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..502856e9c8 --- /dev/null +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/lib/memdump_cmd_crc_conversion.py @@ -0,0 +1,42 @@ +# 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, 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): + 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 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 + 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 0dcab4db26..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 @@ -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,8 @@ def __init__(self, target_name): self.time_offset = 0 self.ip_address = 0 self.variable_arrays_updated = False + self.memdump_response = None + self.crc = Crc32() def set_rates(self): self.set_rate("ADCS", 10) @@ -244,6 +251,28 @@ 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") + 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 + 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 = 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) # 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 + self.memdump_response = (offset, bytes(data), crc) def solar_panel_thread_method(self): self.solar_panel_thread_cancel = False @@ -467,4 +496,18 @@ def read(self, count_100hz, time): arrays_packet.write("ccsdsseqcnt", arrays_packet.read("ccsdsseqcnt") + 1) pending_packets.append(arrays_packet) + 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)) + 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..c685bb908d --- /dev/null +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/memdump.py @@ -0,0 +1,138 @@ +# 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 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 +# +# Environment variables: +# BAD_CRC=1 flips a bit in one chunk part way through the dump to show recovery from a bad CRC +import os +import re +import time +from datetime import datetime, timezone + +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 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 + +crc = Crc32() +bad_crc = os.environ.get("BAD_CRC") +end_offset = START_OFFSET + TOTAL_SIZE +dump_data = bytearray() # The verified dump is small enough to keep in memory +offset = START_OFFSET +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 +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 + chunk_bad_crc = "FALSE" + if bad_crc and offset >= bad_crc_offset: + chunk_bad_crc = "TRUE" + bad_crc = 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, 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 + 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 + + # 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"]: + print(f"CRC passed at OFFSET {offset}") + data = chunk + else: + 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}") + # 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 + offset += size + +print(f"Dumped {len(dump_data)} bytes (CRC32 0x{crc.calc(dump_data):08X})") + +# Push the dump to the bucket +put_target_file(bucket_path, bytes(dump_data)) + +# 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..960150c696 --- /dev/null +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/screens/memdump.txt @@ -0,0 +1,40 @@ +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 + # 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();" + + "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 '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 + + 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/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') })