diff --git a/arduino/CommandStation-EX b/arduino/CommandStation-EX index 313d2cd..428d721 160000 --- a/arduino/CommandStation-EX +++ b/arduino/CommandStation-EX @@ -1 +1 @@ -Subproject commit 313d2cd3e044e347f588af1d33569809d17b2d8d +Subproject commit 428d721a9ce654af883e0b9ec0d3d35a8affc1ae diff --git a/ram/roster/management/__init__.py b/ram/roster/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ram/roster/management/commands/__init__.py b/ram/roster/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ram/roster/management/commands/export_jmri_roster.py b/ram/roster/management/commands/export_jmri_roster.py new file mode 100644 index 0000000..259022c --- /dev/null +++ b/ram/roster/management/commands/export_jmri_roster.py @@ -0,0 +1,278 @@ +import os +import re +from xml.etree import ElementTree as ET +from xml.dom import minidom +from django.core.management.base import BaseCommand, CommandError + +from portal.utils import get_site_conf +from roster.models import RollingStock + + +class Command(BaseCommand): + help = "Export roster items to JMRI-compatible XML format" + + def add_arguments(self, parser): + parser.add_argument( + "--output", + type=str, + default="jmri_roster_export", + help="Output directory for JMRI roster files (default: jmri_roster_export)", # noqa: E501 + ) + parser.add_argument( + "--uuid", + type=str, + help="Export only a specific roster item by UUID", + ) + parser.add_argument( + "--published-only", + action="store_true", + help="Export only published roster items", + ) + + def handle(self, *args, **options): + output_dir = options["output"] + uuid_filter = options["uuid"] + published_only = options["published_only"] + + # Create output directory if it doesn't exist + if not os.path.exists(output_dir): + os.makedirs(output_dir) + self.stdout.write( + self.style.SUCCESS(f"Created output directory: {output_dir}") + ) + + # Query roster items + roster_items = RollingStock.objects.all() + + if uuid_filter: + roster_items = roster_items.filter(uuid=uuid_filter) + if not roster_items.exists(): + raise CommandError( + f'RollingStock with UUID "{uuid_filter}" does not exist' + ) + elif published_only: + roster_items = roster_items.filter(published=True) + + roster_items = roster_items.select_related( + "rolling_class", + "rolling_class__company", + "rolling_class__type", + "manufacturer", + "scale", + "decoder", + "decoder__manufacturer", + ) + + if not roster_items.exists(): + self.stdout.write(self.style.WARNING("No roster items to export")) + return + + owner = get_site_conf().site_author + + # Export each roster item + exported_count = 0 + for item in roster_items: + try: + filename = self.export_roster_item(item, output_dir, owner) + self.stdout.write( + self.style.SUCCESS(f"Exported: {item} -> {filename}") + ) + exported_count += 1 + except Exception as e: + self.stdout.write( + self.style.ERROR(f"Error exporting {item}: {str(e)}") + ) + + self.stdout.write( + self.style.SUCCESS( + f"\nSuccessfully exported {exported_count} roster item(s) to {output_dir}" # noqa: E501 + ) + ) + + def export_roster_item(self, item, output_dir, owner=""): + """ + Export a single RollingStock item to JMRI-compatible XML format. + """ + # Create root element + root = ET.Element("locomotive-config") + + # Add comment with export information + comment = ET.Comment( + f"Exported from Django-RAM on {item.updated_time.strftime('%a %b %d %H:%M:%S %Z %Y')}" # noqa: E501 + ) + root.append(comment) + + # Create locomotive element + locomotive = ET.SubElement(root, "locomotive") + + # Set locomotive attributes + # ID: Use rolling_class + road_number as identifier + roster_id = str(item) + locomotive.set("id", roster_id) + + # Road number and name + locomotive.set("roadNumber", item.road_number) + locomotive.set("roadName", str(item.rolling_class.company)) + + # Manufacturer and model + locomotive.set( + "mfg", item.manufacturer.name if item.manufacturer else "" + ) + locomotive.set("model", item.item_number or "") + + # DCC Address + locomotive.set("dccAddress", str(item.address) if item.address else "") + + # Owner from site configuration + locomotive.set("owner", owner) + + # Comment from description (HTML stripped) + comment_text = "" + if item.description: + comment_text = re.sub(r"<[^>]+>", "", item.description).strip() + locomotive.set("comment", comment_text[:500]) # Limit length + + # Create decoder element + decoder = ET.SubElement(locomotive, "decoder") + + if item.decoder: + decoder.set("model", item.decoder.name) + decoder.set( + "family", + f"{item.decoder.manufacturer.name} {item.decoder.name}", + ) + decoder_comment = [] + if item.decoder.sound: + decoder_comment.append("Sound") + if item.decoder.version: + decoder_comment.append(f"Version: {item.decoder.version}") + if item.decoder_interface: + decoder_comment.append( + f"Interface: {item.get_decoder_interface()}" + ) + decoder.set("comment", " | ".join(decoder_comment)) + else: + decoder.set("model", "") + decoder.set("family", "") + if item.decoder_interface: + decoder.set( + "comment", f"Interface: {item.get_decoder_interface()}" + ) + else: + decoder.set("comment", "No decoder installed") + + # Create locoaddress element + if item.address: + long_address = item.address > 127 + protocol = "dcc_long" if long_address else "dcc_short" + locoaddress = ET.SubElement(locomotive, "locoaddress") + dcclocoaddress = ET.SubElement(locoaddress, "dcclocoaddress") + dcclocoaddress.set("number", str(item.address)) + dcclocoaddress.set( + "longaddress", "yes" if long_address else "no" + ) + number = ET.SubElement(locoaddress, "number") + number.text = str(item.address) + protocol_el = ET.SubElement(locoaddress, "protocol") + protocol_el.text = protocol + + # Add custom attributes section for django-ram specific data + # DTD order: decoder, locoaddress*, functionlabels?, attributepairs?, values? + attributes = ET.SubElement(locomotive, "attributepairs") + + # Store UUID for reference + attr = ET.SubElement(attributes, "keyvaluepair") + key = ET.SubElement(attr, "key") + key.text = "django-ram-uuid" + value = ET.SubElement(attr, "value") + value.text = str(item.uuid) + + # Store scale + attr = ET.SubElement(attributes, "keyvaluepair") + key = ET.SubElement(attr, "key") + key.text = "scale" + value = ET.SubElement(attr, "value") + value.text = str(item.scale) + + # Store rolling class type + attr = ET.SubElement(attributes, "keyvaluepair") + key = ET.SubElement(attr, "key") + key.text = "type" + value = ET.SubElement(attr, "value") + value.text = str(item.rolling_class.type) + + # Store era if available + if item.era: + attr = ET.SubElement(attributes, "keyvaluepair") + key = ET.SubElement(attr, "key") + key.text = "era" + value = ET.SubElement(attr, "value") + value.text = item.era + + # Store production year if available + if item.production_year: + attr = ET.SubElement(attributes, "keyvaluepair") + key = ET.SubElement(attr, "key") + key.text = "production_year" + value = ET.SubElement(attr, "value") + value.text = str(item.production_year) + + # Create values section (must come after attributepairs per DTD) + values = ET.SubElement(locomotive, "values") + decoder_def = ET.SubElement(values, "decoderDef") + + # Add basic CV values if we have an address + if item.address: + # Primary Address (CV1) + if item.address <= 127: + var_value = ET.SubElement(decoder_def, "varValue") + var_value.set("item", "Primary Address") + var_value.set("value", str(item.address)) + + # Add CV value + cv_value = ET.SubElement(values, "CVvalue") + cv_value.set("name", "1") + cv_value.set("value", str(item.address)) + else: + # Extended Address (CV17 and CV18) + # CV17 = (address / 256) + 192 + # CV18 = address % 256 + cv17 = (item.address // 256) + 192 + cv18 = item.address % 256 + + var_value = ET.SubElement(decoder_def, "varValue") + var_value.set("item", "Extended Address") + var_value.set("value", str(item.address)) + + cv_value_17 = ET.SubElement(values, "CVvalue") + cv_value_17.set("name", "17") + cv_value_17.set("value", str(cv17)) + + cv_value_18 = ET.SubElement(values, "CVvalue") + cv_value_18.set("name", "18") + cv_value_18.set("value", str(cv18)) + + # Pretty print XML + xml_str = minidom.parseString( + ET.tostring(root, encoding="utf-8") + ).toprettyxml(indent=" ", encoding="UTF-8") + + # Add DOCTYPE declaration + doctype = b'\n' # noqa: E501 + xml_lines = xml_str.split(b"\n") + # Insert DOCTYPE after XML declaration + xml_output = xml_lines[0] + b"\n" + doctype + b"\n".join(xml_lines[1:]) + + # Generate filename: sanitize roster ID + safe_filename = "".join( + c if c.isalnum() or c in ("-", "_", " ") else "_" + for c in roster_id + ) + filename = f"{safe_filename}.xml" + filepath = os.path.join(output_dir, filename) + + # Write to file + with open(filepath, "wb") as f: + f.write(xml_output) + + return filename