jmri_core.persistence.roster_xml
locomotive-config XML load/save — see contracts/xml/schema/locomotive-config.xsd.
A locomotive-config file contains one or more <locomotive> elements. Each
maps to a ~jmri_core.beans.roster_entry.RosterEntry. The mapping is
attribute-by-attribute and the round-trip is lossless for every field the
schema declares.
XML normalization rules (matching JMRI's Java writer):
- attribute order matches the order declared in the XSD;
- boolean attributes are written
"yes"/"no"(nottrue/false); - missing optional values are omitted (not written as empty);
- element order:
decoder, locoaddress, functionlabels, soundlabels, attributepairs.
1"""``locomotive-config`` XML load/save — see ``contracts/xml/schema/locomotive-config.xsd``. 2 3A locomotive-config file contains one or more ``<locomotive>`` elements. Each 4maps to a :class:`~jmri_core.beans.roster_entry.RosterEntry`. The mapping is 5attribute-by-attribute and the round-trip is lossless for every field the 6schema declares. 7 8XML normalization rules (matching JMRI's Java writer): 9 10* attribute order matches the order declared in the XSD; 11* boolean attributes are written ``"yes"``/``"no"`` (not ``true``/``false``); 12* missing optional values are *omitted* (not written as empty); 13* element order: ``decoder, locoaddress, functionlabels, soundlabels, 14 attributepairs``. 15""" 16from __future__ import annotations 17 18import pathlib 19 20from lxml import etree 21 22from jmri_core.beans.roster_entry import FunctionLabel, RosterEntry, SoundLabel 23 24# JMRI stores maxSpeed as a fraction 0..1; our model uses 0..100. 25_DEFAULT_MAX_SPEED_PCT = 100 26 27# Attribute order on <locomotive> — matches the XSD declaration order. 28_LOCO_ATTR_ORDER: tuple[str, ...] = ( 29 "id", 30 "fileName", 31 "roadNumber", 32 "roadName", 33 "mfg", 34 "owner", 35 "model", 36 "dccAddress", 37 "comment", 38 "maxSpeed", 39 "imageFilePath", 40 "iconFilePath", 41 "URL", 42) 43 44 45# ----- public API ---------------------------------------------------------- 46 47 48def load_locomotive_bytes(data: bytes) -> list[RosterEntry]: 49 """Parse a locomotive-config XML byte string into RosterEntry objects.""" 50 parser = etree.XMLParser(remove_blank_text=False) 51 root = etree.fromstring(data, parser=parser) 52 return _parse_root(root) 53 54 55def load_locomotive(path: str | pathlib.Path) -> list[RosterEntry]: 56 """Parse a locomotive-config XML file path.""" 57 p = pathlib.Path(path) 58 return load_locomotive_bytes(p.read_bytes()) 59 60 61def save_locomotive_bytes(entries: list[RosterEntry]) -> bytes: 62 """Serialize *entries* to locomotive-config XML bytes.""" 63 root = etree.Element("locomotive-config") 64 for e in entries: 65 root.append(_entry_to_element(e)) 66 return etree.tostring( 67 root, 68 xml_declaration=True, 69 encoding="utf-8", 70 pretty_print=True, 71 standalone=False, 72 ) 73 74 75def save_locomotive(path: str | pathlib.Path, entries: list[RosterEntry]) -> None: 76 pathlib.Path(path).write_bytes(save_locomotive_bytes(entries)) 77 78 79# ----- parsing ------------------------------------------------------------- 80 81 82def _parse_root(root: etree._Element) -> list[RosterEntry]: 83 if root.tag != "locomotive-config": 84 raise ValueError(f"expected <locomotive-config>, got <{root.tag}>") 85 return [_element_to_entry(loco) for loco in root.findall("locomotive")] 86 87 88def _element_to_entry(loco: etree._Element) -> RosterEntry: 89 attrs = loco.attrib 90 91 # Sub-elements first; their values can override attribute fallbacks. 92 decoder = loco.find("decoder") 93 decoder_family = decoder.get("family", "") if decoder is not None else "" 94 decoder_model = decoder.get("model", "") if decoder is not None else "" 95 decoder_comment = decoder.get("comment", "") if decoder is not None else "" 96 97 addr_block = loco.find("locoaddress") 98 if addr_block is not None: 99 ll = addr_block.find("dcclocoaddress") 100 if ll is not None: 101 dcc_address = ll.get("number", "3") 102 long_address = ll.get("longaddress", "no") == "yes" 103 else: 104 dcc_address = attrs.get("dccAddress", "3") 105 long_address = False 106 protocol_elem = addr_block.find("protocol") 107 protocol = protocol_elem.text or "dcc_short" if protocol_elem is not None else "dcc_short" 108 else: 109 dcc_address = attrs.get("dccAddress", "3") 110 long_address = False 111 protocol = "dcc_short" 112 113 fls = loco.find("functionlabels") 114 function_labels: list[FunctionLabel] = [ 115 FunctionLabel( 116 number=int(fl.get("num", "0")), 117 label=fl.text or "", 118 lockable=_to_bool(fl.get("lockable", "true")), 119 image=fl.get("functionImage"), 120 selected_image=fl.get("functionImageSelected"), 121 ) 122 for fl in (fls.findall("functionlabel") if fls is not None else []) 123 ] 124 125 sls = loco.find("soundlabels") 126 sound_labels: list[SoundLabel] = [ 127 SoundLabel(number=int(sl.get("num", "0")), label=sl.text or "") 128 for sl in (sls.findall("soundlabel") if sls is not None else []) 129 ] 130 131 attributes: dict[str, str] = {} 132 for ap in loco.findall("attributepairs"): 133 for kv in ap.findall("keyvaluepair"): 134 k = kv.find("key") 135 v = kv.find("value") 136 if k is not None and v is not None: 137 attributes[k.text or ""] = v.text or "" 138 139 # Preserve unmodeled child elements (most importantly ``<values>`` — 140 # the decoder CV state for in-roster-file storage). Without this, 141 # editing a loco's road name in our UI would silently destroy its 142 # programmed CVs on the next save. 143 _modeled = { 144 "decoder", "locoaddress", "functionlabels", 145 "soundlabels", "attributepairs", 146 } 147 preserved_xml: list[str] = [] 148 for child in loco: 149 if not isinstance(child.tag, str): 150 # Comments / processing instructions — preserve. 151 preserved_xml.append( 152 etree.tostring(child, encoding="unicode") 153 ) 154 continue 155 if child.tag in _modeled: 156 continue 157 preserved_xml.append(etree.tostring(child, encoding="unicode")) 158 159 return RosterEntry( 160 id=attrs.get("id", ""), 161 file_name=attrs.get("fileName", ""), 162 dcc_address=dcc_address, 163 long_address=long_address, 164 protocol=protocol, 165 decoder_family=decoder_family, 166 decoder_model=decoder_model, 167 decoder_comment=decoder_comment, 168 road_name=attrs.get("roadName", ""), 169 road_number=attrs.get("roadNumber", ""), 170 mfg=attrs.get("mfg", ""), 171 owner=attrs.get("owner", ""), 172 model=attrs.get("model", ""), 173 comment=attrs.get("comment", ""), 174 max_speed_pct=_max_speed_pct_from_attr(attrs.get("maxSpeed")), 175 image_file_path=attrs.get("imageFilePath"), 176 icon_file_path=attrs.get("iconFilePath"), 177 url=attrs.get("URL"), 178 function_labels=function_labels, 179 sound_labels=sound_labels, 180 attributes=attributes, 181 preserved_xml=preserved_xml, 182 ) 183 184 185# ----- serialization ------------------------------------------------------- 186 187 188def _entry_to_element(entry: RosterEntry) -> etree._Element: 189 loco = etree.Element("locomotive") 190 191 raw_attrs: dict[str, str | None] = { 192 "id": entry.id, 193 "fileName": entry.file_name or None, 194 "roadNumber": entry.road_number or None, 195 "roadName": entry.road_name or None, 196 "mfg": entry.mfg or None, 197 "owner": entry.owner or None, 198 "model": entry.model or None, 199 "dccAddress": entry.dcc_address or None, 200 "comment": entry.comment or None, 201 "maxSpeed": ( 202 f"{entry.max_speed_pct / 100:.2f}" 203 if entry.max_speed_pct != _DEFAULT_MAX_SPEED_PCT 204 else None 205 ), 206 "imageFilePath": entry.image_file_path, 207 "iconFilePath": entry.icon_file_path, 208 "URL": entry.url, 209 } 210 for key in _LOCO_ATTR_ORDER: 211 v = raw_attrs.get(key) 212 if v is not None and v != "": 213 loco.set(key, v) 214 215 # <decoder> 216 decoder = etree.SubElement(loco, "decoder") 217 decoder.set("family", entry.decoder_family) 218 decoder.set("model", entry.decoder_model) 219 if entry.decoder_comment: 220 decoder.set("comment", entry.decoder_comment) 221 222 # <locoaddress> 223 addr = etree.SubElement(loco, "locoaddress") 224 dcc = etree.SubElement(addr, "dcclocoaddress") 225 dcc.set("number", entry.dcc_address) 226 dcc.set("longaddress", "yes" if entry.long_address else "no") 227 proto = etree.SubElement(addr, "protocol") 228 proto.text = entry.protocol 229 230 # <functionlabels> 231 if entry.function_labels: 232 fls = etree.SubElement(loco, "functionlabels") 233 for fl in entry.function_labels: 234 el = etree.SubElement(fls, "functionlabel") 235 el.set("num", str(fl.number)) 236 el.set("lockable", "true" if fl.lockable else "false") 237 if fl.image: 238 el.set("functionImage", fl.image) 239 if fl.selected_image: 240 el.set("functionImageSelected", fl.selected_image) 241 el.text = fl.label 242 243 # <soundlabels> 244 if entry.sound_labels: 245 sls = etree.SubElement(loco, "soundlabels") 246 for sl in entry.sound_labels: 247 el = etree.SubElement(sls, "soundlabel") 248 el.set("num", str(sl.number)) 249 el.text = sl.label 250 251 # <attributepairs> 252 if entry.attributes: 253 ap = etree.SubElement(loco, "attributepairs") 254 for k, v in entry.attributes.items(): 255 kv = etree.SubElement(ap, "keyvaluepair") 256 etree.SubElement(kv, "key").text = k 257 etree.SubElement(kv, "value").text = v 258 259 # Append preserved children verbatim (CV state, dateUpdated, etc.). 260 for raw in entry.preserved_xml: 261 try: 262 elem = etree.fromstring(raw) 263 except etree.XMLSyntaxError: 264 continue 265 loco.append(elem) 266 267 return loco 268 269 270# ----- helpers ------------------------------------------------------------- 271 272 273def _to_bool(s: str) -> bool: 274 return s.lower() in ("yes", "true", "1") 275 276 277def _max_speed_pct_from_attr(raw: str | None) -> int: 278 """JMRI stores maxSpeed as a fraction 0..1; RosterEntry uses 0..100.""" 279 if raw is None or raw == "": 280 return 100 281 try: 282 f = float(raw) 283 except ValueError: 284 return 100 285 return max(0, min(100, round(f * 100)))
49def load_locomotive_bytes(data: bytes) -> list[RosterEntry]: 50 """Parse a locomotive-config XML byte string into RosterEntry objects.""" 51 parser = etree.XMLParser(remove_blank_text=False) 52 root = etree.fromstring(data, parser=parser) 53 return _parse_root(root)
Parse a locomotive-config XML byte string into RosterEntry objects.
56def load_locomotive(path: str | pathlib.Path) -> list[RosterEntry]: 57 """Parse a locomotive-config XML file path.""" 58 p = pathlib.Path(path) 59 return load_locomotive_bytes(p.read_bytes())
Parse a locomotive-config XML file path.
62def save_locomotive_bytes(entries: list[RosterEntry]) -> bytes: 63 """Serialize *entries* to locomotive-config XML bytes.""" 64 root = etree.Element("locomotive-config") 65 for e in entries: 66 root.append(_entry_to_element(e)) 67 return etree.tostring( 68 root, 69 xml_declaration=True, 70 encoding="utf-8", 71 pretty_print=True, 72 standalone=False, 73 )
Serialize entries to locomotive-config XML bytes.
def
save_locomotive( path: str | pathlib._local.Path, entries: list[jmri_core.beans.RosterEntry]) -> None: