Skip to content
21 changes: 13 additions & 8 deletions openc3/lib/openc3/packets/packet_item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -398,14 +398,19 @@ def to_config(cmd_or_tlm, default_endianness)
if @states
@states.each do |state_name, state_value|
config << " STATE #{state_name.to_s.quote_if_necessary} #{state_value.to_s.quote_if_necessary}"
if @hazardous and @hazardous[state_name]
config << " HAZARDOUS #{@hazardous[state_name].to_s.quote_if_necessary}"
end
if @messages_disabled and @messages_disabled[state_name]
config << " DISABLE_MESSAGES"
end
if @state_colors and @state_colors[state_name]
config << " #{@state_colors[state_name]}"
# Only write the attributes STATE accepts for this packet type so the config parses back
if cmd_or_tlm == :TELEMETRY
if @state_colors and @state_colors[state_name]
config << " #{@state_colors[state_name]}"
end
else
if @hazardous and @hazardous[state_name]
config << " HAZARDOUS"
config << " #{@hazardous[state_name].to_s.quote_if_necessary}" unless @hazardous[state_name].to_s.empty?
end
if @messages_disabled and @messages_disabled[state_name]
config << " DISABLE_MESSAGES"
end
end
config << "\n"
end
Expand Down
7 changes: 5 additions & 2 deletions openc3/python/openc3/conversions/conversion.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2024 OpenC3, Inc.
# Copyright 2026 OpenC3, Inc.
# All Rights Reserved.
#
# This program is distributed in the hope that it will be useful,
Expand All @@ -10,6 +10,9 @@
# if purchased from OpenC3, Inc.


from openc3.utilities.string import class_to_filename


class Conversion:
"""Performs a general conversion via the implementation of the call method"""

Expand Down Expand Up @@ -47,7 +50,7 @@ def __str__(self):
# self.param read_or_write [String] Either 'READ' or 'WRITE'
# self.return [String] Config fragment for this conversion
def to_config(self, read_or_write):
return f" {read_or_write}_CONVERSION {self.__class__.__name__}\n"
return f" {read_or_write}_CONVERSION {class_to_filename(self.__class__)}\n"

def as_json(self):
result = {}
Expand Down
4 changes: 2 additions & 2 deletions openc3/python/openc3/conversions/ip_write_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,5 @@ def __str__(self):

# @param read_or_write [String] Either 'READ' or 'WRITE'
# @return [String] Config fragment for this conversion
def to_config(self):
return f"{self}_CONVERSION openc3/conversions/ip_write_conversion.py\n"
def to_config(self, read_or_write):
return f"{read_or_write}_CONVERSION openc3/conversions/ip_write_conversion.py\n"
5 changes: 3 additions & 2 deletions openc3/python/openc3/conversions/processor_conversion.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2024 OpenC3, Inc.
# Copyright 2026 OpenC3, Inc.
# All Rights Reserved.
#
# This program is distributed in the hope that it will be useful,
Expand All @@ -13,6 +13,7 @@
from openc3.accessors.binary_accessor import BinaryAccessor
from openc3.config.config_parser import ConfigParser
from openc3.conversions.conversion import Conversion
from openc3.utilities.string import class_to_filename


class ProcessorConversion(Conversion):
Expand Down Expand Up @@ -61,7 +62,7 @@ def __str__(self):
# @param (see Conversion#to_config)
# @return [String] Config fragment for this conversion
def to_config(self, read_or_write):
config = f" {read_or_write}_CONVERSION {self.__class__.__name__} {self.processor_name} {self.result_name}"
config = f" {read_or_write}_CONVERSION {class_to_filename(self.__class__)} {self.processor_name} {self.result_name}"
if self.converted_type is not None:
config += f" {self.converted_type}"
if self.converted_bit_size is not None:
Expand Down
6 changes: 5 additions & 1 deletion openc3/python/openc3/conversions/unix_time_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from datetime import datetime, timezone

from openc3.conversions.conversion import Conversion
from openc3.utilities.string import class_to_filename


# Converts a unix format time: Epoch Jan 1 1970, seconds and microseconds
Expand Down Expand Up @@ -54,4 +55,7 @@ def __str__(self):
# @param (see Conversion#to_config)
# @return [String] Config fragment for this conversion
def to_config(self, read_or_write):
return f" {read_or_write}_CONVERSION {self.__class__.__name__} {self.seconds_item_name} {self.microseconds_item_name}\n"
config = f" {read_or_write}_CONVERSION {class_to_filename(self.__class__)} {self.seconds_item_name}"
if self.microseconds_item_name:
config += f" {self.microseconds_item_name}"
return config + "\n"
5 changes: 4 additions & 1 deletion openc3/python/openc3/packets/limits_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
# if purchased from OpenC3, Inc.


from openc3.utilities.string import class_to_filename


class LimitsResponse:
"""This class defines a #call method which is called case a PacketItem
goes out of limits. This class must be subclassed and the call method
Expand All @@ -27,7 +30,7 @@ def call(self, packet, item, old_limits_state):
raise RuntimeError("call method must be defined by subclass")

def to_config(self):
return f" LIMITS_RESPONSE {self.__class__.__name__}\n"
return f" LIMITS_RESPONSE {class_to_filename(self.__class__)}\n"

def as_json(self):
return {"class": self.__class__.__name__}
17 changes: 9 additions & 8 deletions openc3/python/openc3/packets/packet.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
)
from openc3.utilities.logger import Logger
from openc3.utilities.string import (
class_to_filename,
quote_if_necessary,
simple_formatted,
)
Expand Down Expand Up @@ -1008,7 +1009,7 @@ def reset(self):
if not self.processors:
return

for _, processor in self.processors:
for processor in self.processors.values():
processor.reset()

# Make a light weight clone of this packet. This only creates a new buffer
Expand Down Expand Up @@ -1051,13 +1052,13 @@ def to_config(self, cmd_or_tlm):
config += f'COMMAND {quote_if_necessary(self.target_name)} {quote_if_necessary(self.packet_name)} {self.default_endianness} "{self.description}"\n'
if self.subpacketizer:
args_str = " ".join([quote_if_necessary(str(a)) for a in self.subpacketizer.args])
config += f" SUBPACKETIZER {self.subpacketizer.__class__.__name__} {args_str}\n"
config += f" SUBPACKETIZER {class_to_filename(self.subpacketizer.__class__)} {args_str}\n"
if self.accessor.__class__.__name__ != "BinaryAccessor":
args_str = " ".join([quote_if_necessary(str(a)) for a in self.accessor.args])
config += f" ACCESSOR {self.accessor.__class__.__name__} {args_str}\n"
config += f" ACCESSOR {class_to_filename(self.accessor.__class__)} {args_str}\n"
if self.validator:
args_str = " ".join([quote_if_necessary(str(a)) for a in self.validator.args])
config += f" VALIDATOR {self.validator.__class__.__name__} {args_str}\n"
config += f" VALIDATOR {class_to_filename(self.validator.__class__)} {args_str}\n"
if self.template:
config += f" TEMPLATE_BASE64 {base64.b64encode(self.template).decode('ascii')}\n"
if self.short_buffer_allowed:
Expand All @@ -1080,11 +1081,11 @@ def to_config(self, cmd_or_tlm):
config += " RESTRICTED\n"

if self.processors:
for _, processor in self.processors:
config += processor.to_config
for processor in self.processors.values():
config += processor.to_config()

if self.meta:
for key, values in self.meta:
for key, values in self.meta.items():
vals = " ".join([quote_if_necessary(a) for a in values])
config += f" META {quote_if_necessary(key)} {vals}\n"

Expand Down Expand Up @@ -1151,7 +1152,7 @@ def as_json(self):

if self.processors:
processors = []
for _, processor in self.processors:
for processor in self.processors.values():
processors.append(processor.as_json())
config["processors"] = processors

Expand Down
21 changes: 13 additions & 8 deletions openc3/python/openc3/packets/packet_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ def to_config(self, cmd_or_tlm, default_endianness):
else:
config += self.parameter_config()
if self.endianness != default_endianness and self.data_type != "STRING" and self.data_type != "BLOCK":
config += " {self.endianness}"
config += f" {self.endianness}"
config += "\n"

if self.required:
Expand All @@ -415,12 +415,17 @@ def to_config(self, cmd_or_tlm, default_endianness):
if self.states:
for state_name, state_value in self.states.items():
config += f" STATE {quote_if_necessary(state_name)} {quote_if_necessary(str(state_value))}"
if self.hazardous and self.hazardous[state_name]:
config += f" HAZARDOUS {quote_if_necessary(self.hazardous[state_name])}"
if self.messages_disabled and self.messages_disabled[state_name]:
config += " DISABLE_MESSAGES"
if self.state_colors and self.state_colors[state_name]:
config += f" {self.state_colors[state_name]}"
# Only write the attributes STATE accepts for this packet type so the config parses back
if cmd_or_tlm == "TELEMETRY":
if self.state_colors and self.state_colors.get(state_name):
config += f" {self.state_colors[state_name]}"
else:
if self.hazardous and self.hazardous.get(state_name) is not None:
config += " HAZARDOUS"
if self.hazardous[state_name]:
config += f" {quote_if_necessary(self.hazardous[state_name])}"
if self.messages_disabled and self.messages_disabled.get(state_name):
config += " DISABLE_MESSAGES"
config += "\n"

if self.read_conversion:
Expand All @@ -438,7 +443,7 @@ def to_config(self, cmd_or_tlm, default_endianness):
else:
config += "\n"
if limits.response:
config += limits.response.to_config
config += limits.response.to_config()

if self.__meta:
for key, values in self.__meta.items():
Expand Down
3 changes: 2 additions & 1 deletion openc3/python/openc3/processors/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import copy

from openc3.packets.packet import Packet
from openc3.utilities.string import class_to_filename


class Processor:
Expand Down Expand Up @@ -60,7 +61,7 @@ def clone(self):

# Convert to configuration file string
def to_config(self):
return f" PROCESSOR {self.name} {self.__class__.__name__} {self.value_type}\n"
return f" PROCESSOR {self.name} {class_to_filename(self.__class__)} {self.value_type}\n"

def as_json(self):
return {
Expand Down
3 changes: 2 additions & 1 deletion openc3/python/openc3/processors/statistics_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import statistics

from openc3.processors.processor import Processor
from openc3.utilities.string import class_to_filename


class StatisticsProcessor(Processor):
Expand Down Expand Up @@ -63,7 +64,7 @@ def clone(self):

# Convert to configuration file string
def to_config(self):
return f" PROCESSOR {self.name} {self.__class__.__name__} {self.item_name} {self.samples_to_average} {self.value_type}\n"
return f" PROCESSOR {self.name} {class_to_filename(self.__class__)} {self.item_name} {self.samples_to_average} {self.value_type}\n"

def as_json(self):
return {
Expand Down
5 changes: 3 additions & 2 deletions openc3/python/openc3/processors/watermark_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
# if purchased from OpenC3, Inc.

from openc3.processors.processor import Processor
from openc3.utilities.string import class_to_filename


class WatermarkProcessor(Processor):
Expand Down Expand Up @@ -39,11 +40,11 @@ def reset(self):

# Convert to configuration file string
def to_config(self):
return f" PROCESSOR {self.name} {self.__class__.__name__} {self.item_name} {self.value_type}\n"
return f" PROCESSOR {self.name} {class_to_filename(self.__class__)} {self.item_name} {self.value_type}\n"

def as_json(self):
return {
"name": self.name,
"class": self.__class__.__name__.name,
"class": self.__class__.__name__,
"params": [self.item_name, self.value_type],
}
9 changes: 9 additions & 0 deletions openc3/python/openc3/utilities/string.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ def class_name_to_filename(string, include_extension=False):
return filename


# Returns the filename config keywords load a class from, i.e. the class
# MyGreatClass in module path.to.my_great_class returns "path/to/my_great_class.py".
# This is the inverse of filename_to_module and filename_to_class_name.
#
# self.return [String] Filename which implements the class
def class_to_filename(klass):
return klass.__module__.replace(".", "/") + ".py"


# Converts a String representing a filename (i.e. "path/to/my_great_class.py")
# to a Python class name (i.e. "MyGreatClass").
#
Expand Down
26 changes: 26 additions & 0 deletions openc3/python/test/packets/test_packet.py
Original file line number Diff line number Diff line change
Expand Up @@ -2340,3 +2340,29 @@ def test_does_not_identify_a_packet_whose_id_item_is_outside_the_buffer(self):
self.assertFalse(packet.identify(b"\x00\x01"))
self.assertTrue(packet.identify(b"\x00\x01\x00\x05"))
self.assertFalse(packet.identify(b"\x00\x01\x00\x06"))


class TestPacketProcessors(unittest.TestCase):
def test_reset_resets_each_processor(self):
p = Packet("TGT", "PKT")
processor = Processor()
processor.reset = Mock()
p.processors["PROC"] = processor
p.reset()
processor.reset.assert_called_once()

def test_as_json_includes_each_processor(self):
p = Packet("TGT", "PKT")
p.processors["PROC"] = Processor()
self.assertEqual(p.as_json()["processors"], [Processor().as_json()])


class TestPacketToConfig(unittest.TestCase):
def test_writes_processors_and_meta(self):
p = Packet("TGT", "PKT")
p.append_item("ITEM", 8, "UINT")
p.processors["PROC"] = Processor()
p.meta["TYPE"] = ["SENSOR", "LIGHT"]
config = p.to_config("TELEMETRY")
self.assertIn(" PROCESSOR PROCESSOR openc3/processors/processor.py CONVERTED\n", config)
self.assertIn(" META TYPE SENSOR LIGHT\n", config)
49 changes: 49 additions & 0 deletions openc3/python/test/packets/test_packet_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1776,6 +1776,55 @@ def test_to_config_exports_limits_groups(self):

os.unlink(tf.name)

def test_to_config_output_parses_back_to_the_same_classes(self):
"""Test that keywords naming a class are written as a filename the parser can load"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tf:
tf.write('TELEMETRY TGT1 PKT1 BIG_ENDIAN "Packet"\n')
tf.write(" SUBPACKETIZER openc3/subpacketizers/subpacketizer.py\n")
tf.write(" PROCESSOR WATER openc3/processors/watermark_processor.py ITEM1\n")
tf.write(" PROCESSOR STATS openc3/processors/statistics_processor.py ITEM1 10\n")
tf.write(" ITEM ITEM1 0 16 UINT\n")
tf.write(" READ_CONVERSION openc3/conversions/received_count_conversion.py\n")
tf.write(" ITEM ITEM2 16 16 UINT\n")
tf.write(" READ_CONVERSION openc3/conversions/unix_time_conversion.py ITEM1\n")
tf.write(" ITEM ITEM3 32 16 UINT\n")
tf.write(" READ_CONVERSION openc3/conversions/processor_conversion.py WATER HIGH_WATER UINT 16\n")
tf.write(" LIMITS DEFAULT 1 ENABLED 1 2 6 7\n")
tf.write(" LIMITS_RESPONSE openc3/packets/limits_response.py\n")
tf.write('TELEMETRY TGT1 PKT2 BIG_ENDIAN "Packet"\n')
tf.write(" ACCESSOR openc3/accessors/json_accessor.py\n")
tf.write(" ITEM ITEM1 0 0 UINT\n")
tf.write(" KEY $.item1\n")
tf.write('COMMAND TGT1 CMD1 BIG_ENDIAN "Command"\n')
tf.write(" VALIDATOR openc3/packets/command_validator.py\n")
tf.write(' PARAMETER PARAM1 0 32 STRING "1.2.3.4"\n')
tf.write(" WRITE_CONVERSION openc3/conversions/ip_write_conversion.py\n")
tf.seek(0)
self.pc.process_file(tf.name, "TGT1")

with tempfile.TemporaryDirectory() as output_dir:
self.pc.to_config(output_dir)
pc = PacketConfig()
pc.process_file(os.path.join(output_dir, "TGT1", "cmd_tlm", "tgt1_tlm.txt"), "TGT1")
pc.process_file(os.path.join(output_dir, "TGT1", "cmd_tlm", "tgt1_cmd.txt"), "TGT1")

def classes(packets):
pkt1 = packets.telemetry["TGT1"]["PKT1"]
cmd1 = packets.commands["TGT1"]["CMD1"]
return {
"subpacketizer": pkt1.subpacketizer.as_json(),
"processors": [processor.as_json() for processor in pkt1.processors.values()],
"conversions": [pkt1.get_item(name).read_conversion.as_json() for name in ["ITEM1", "ITEM2", "ITEM3"]],
"limits_response": pkt1.get_item("ITEM3").limits.response.as_json(),
"accessor": packets.telemetry["TGT1"]["PKT2"].accessor.__class__.__name__,
"validator": cmd1.validator.__class__.__name__,
"write_conversion": cmd1.get_item("PARAM1").write_conversion.as_json(),
}

self.assertEqual(classes(pc), classes(self.pc))

os.unlink(tf.name)

def test_to_config_skips_unknown_target(self):
"""Test that to_config skips UNKNOWN target"""
# PacketConfig always creates UNKNOWN target/packets by default
Expand Down
Loading
Loading