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" (not true/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)))
def load_locomotive_bytes(data: bytes) -> list[jmri_core.beans.RosterEntry]:
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.

def load_locomotive( path: str | pathlib._local.Path) -> list[jmri_core.beans.RosterEntry]:
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.

def save_locomotive_bytes(entries: list[jmri_core.beans.RosterEntry]) -> bytes:
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:
76def save_locomotive(path: str | pathlib.Path, entries: list[RosterEntry]) -> None:
77    pathlib.Path(path).write_bytes(save_locomotive_bytes(entries))