jmri_core.persistence.loco_cv_xml

rosters/<id>.xml — per-locomotive CV state file.

The locomotive-config file at roster/<id>.xml carries identity and function labels; the programmed CV state of a specific decoder is either embedded there as a <values> block (JMRI 5.x default) or kept in a separate rosters/<id>.xml. This module models the separate-file form because that's the layout the alpha tester explicitly asked for in their feedback:

there is a rosters subdirectory that should not be read by default. It contains all the decoder cv settings for the locomotives listed in the roster. These should be loaded as needed by the programmer panel

The format we read/write:

<?xml version="1.0" encoding="UTF-8"?>
<locomotive-cv-values id="459">
  <CV name="1" value="3" />
  <CV name="7" value="151" />
  <CV name="29" value="38" />
</locomotive-cv-values>

When the file doesn't exist yet, an empty LocoCvState is returned. The programmer page accumulates reads + writes into it and flushes back to disk on every mutation.

  1"""``rosters/<id>.xml`` — per-locomotive CV state file.
  2
  3The locomotive-config file at ``roster/<id>.xml`` carries identity and
  4function labels; the *programmed CV state* of a specific decoder is
  5either embedded there as a ``<values>`` block (JMRI 5.x default) or
  6kept in a separate ``rosters/<id>.xml``. This module models the
  7separate-file form because that's the layout the alpha tester
  8explicitly asked for in their feedback:
  9
 10> there is a rosters subdirectory that should *not* be read by default.
 11> It contains all the decoder cv settings for the locomotives listed in
 12> the roster. These should be loaded as needed by the programmer panel
 13
 14The format we read/write:
 15
 16    <?xml version="1.0" encoding="UTF-8"?>
 17    <locomotive-cv-values id="459">
 18      <CV name="1" value="3" />
 19      <CV name="7" value="151" />
 20      <CV name="29" value="38" />
 21    </locomotive-cv-values>
 22
 23When the file doesn't exist yet, an empty :class:`LocoCvState` is
 24returned. The programmer page accumulates reads + writes into it and
 25flushes back to disk on every mutation.
 26"""
 27from __future__ import annotations
 28
 29import pathlib
 30
 31from lxml import etree
 32
 33from jmri_core.beans.loco_cv_state import LocoCvState
 34
 35
 36def load_loco_cv_bytes(data: bytes) -> LocoCvState:
 37    """Parse a ``rosters/<id>.xml`` from bytes."""
 38    parser = etree.XMLParser(
 39        remove_blank_text=False,
 40        resolve_entities=False,
 41        load_dtd=False,
 42        no_network=True,
 43    )
 44    root = etree.fromstring(data, parser=parser)
 45    if root.tag != "locomotive-cv-values":
 46        raise ValueError(
 47            f"expected <locomotive-cv-values> root, got <{root.tag}>"
 48        )
 49    loco_id = root.get("id") or ""
 50    cv_values: dict[str, int] = {}
 51    for cv in root.findall("CV"):
 52        name = cv.get("name")
 53        val = cv.get("value")
 54        if not name or val is None:
 55            continue
 56        try:
 57            cv_values[name] = int(val)
 58        except ValueError:
 59            # Skip malformed entries; don't blow up the rest of the file.
 60            continue
 61    return LocoCvState(loco_id=loco_id, cv_values=cv_values)
 62
 63
 64def load_loco_cv(path: str | pathlib.Path) -> LocoCvState:
 65    """Load a per-loco CV file. Returns an empty state if missing."""
 66    p = pathlib.Path(path)
 67    if not p.exists():
 68        # The id is the file stem (e.g. ``459.xml`` → ``"459"``).
 69        return LocoCvState(loco_id=p.stem)
 70    return load_loco_cv_bytes(p.read_bytes())
 71
 72
 73def save_loco_cv_bytes(state: LocoCvState) -> bytes:
 74    """Serialize a :class:`LocoCvState` to XML."""
 75    root = etree.Element("locomotive-cv-values", attrib={"id": state.loco_id})
 76    # Sort by numeric CV when possible, fall back to lexicographic.
 77    def _key(name: str) -> tuple[int, str]:
 78        try:
 79            return (0, f"{int(name):05d}")
 80        except ValueError:
 81            return (1, name)
 82    for name in sorted(state.cv_values, key=_key):
 83        etree.SubElement(root, "CV", attrib={
 84            "name": name,
 85            "value": str(state.cv_values[name]),
 86        })
 87    return etree.tostring(
 88        root,
 89        xml_declaration=True,
 90        encoding="utf-8",
 91        pretty_print=True,
 92        standalone=False,
 93    )
 94
 95
 96def save_loco_cv(path: str | pathlib.Path, state: LocoCvState) -> None:
 97    target = pathlib.Path(path)
 98    target.parent.mkdir(parents=True, exist_ok=True)
 99    # Atomic write: tmp + rename so a crash mid-write can't corrupt the file.
100    tmp = target.with_suffix(target.suffix + ".tmp")
101    tmp.write_bytes(save_loco_cv_bytes(state))
102    tmp.replace(target)
103
104
105# ----- reading CV state from a JMRI locomotive-config -------------------
106#
107# JMRI's real-world layout embeds CV state inside the roster entry file
108# itself (``roster/<id>.xml``) under a ``<values>`` block. Our proprietary
109# ``rosters/<id>.xml`` was a design choice that diverges from JMRI. When
110# opening a real JMRI profile we must fall back to reading CV values
111# from the roster entry, or the programmer view can't display CVs the
112# user has already programmed.
113
114
115def load_cv_values_from_locomotive_config(
116    path: str | pathlib.Path, *, loco_id: str | None = None,
117) -> LocoCvState | None:
118    """Extract CV state from a ``locomotive-config`` file's ``<values>`` block.
119
120    The JMRI-canonical location for per-loco CV state. Returns ``None`` if
121    the file doesn't exist or has no ``<values>`` block; returns an
122    (empty) :class:`LocoCvState` when the block is present but has no
123    ``<CV>`` rows yet.
124
125    *loco_id* narrows the search when the file lists multiple
126    ``<locomotive>`` entries (rare — JMRI writes one per file). When
127    omitted, the first ``<locomotive>`` wins.
128    """
129    p = pathlib.Path(path)
130    if not p.exists():
131        return None
132    return load_cv_values_from_locomotive_config_bytes(
133        p.read_bytes(), loco_id=loco_id,
134    )
135
136
137def save_cv_values_into_locomotive_config(
138    path: str | pathlib.Path,
139    cv_values: dict[str, int],
140    *,
141    loco_id: str | None = None,
142) -> None:
143    """Update or insert a ``<values>`` block inside a ``locomotive-config``
144    file, preserving every other element.
145
146    * If *path* doesn't exist, does nothing (caller is expected to have
147      created the roster entry first via the roster service).
148    * If *cv_values* is empty, an existing ``<values>`` block is removed
149      entirely — the file still round-trips through the roster loader.
150    * When the file lists multiple locomotives (rare), *loco_id* picks
151      which one gets the update; otherwise the first is used.
152
153    Writes are atomic (tmp + rename) so a crash mid-write can't corrupt
154    the roster entry.
155    """
156    p = pathlib.Path(path)
157    if not p.exists():
158        return
159    parser = etree.XMLParser(
160        remove_blank_text=False,
161        resolve_entities=False,
162        load_dtd=False,
163        no_network=True,
164    )
165    tree = etree.parse(str(p), parser=parser)
166    root = tree.getroot()
167    if root.tag != "locomotive-config":
168        return
169    target_loco = None
170    for loco in root.iter("locomotive"):
171        if loco_id is None or loco.get("id", "") == loco_id:
172            target_loco = loco
173            break
174    if target_loco is None:
175        return
176
177    # Update in place: preserve any existing ``<values>`` sub-elements
178    # that AREN'T our per-CV entries. In particular JMRI Java writes a
179    # sibling ``<decoderDef>`` block inside ``<values>`` that tracks
180    # per-variable human values; wiping the whole ``<values>`` block
181    # (as an earlier revision did) destroys that data. Same story for
182    # any other unrecognised child element we don't own.
183    values_el = target_loco.find("values")
184    if values_el is None:
185        if not cv_values:
186            return
187        values_el = etree.SubElement(target_loco, "values")
188    else:
189        # Remove existing per-CV entries so we don't stack duplicates.
190        # Accept both element shapes we've written across versions.
191        for entry in list(values_el):
192            if isinstance(entry.tag, str) and entry.tag in ("CVvalue", "CV"):
193                values_el.remove(entry)
194
195    def _key(name: str) -> tuple[int, str]:
196        try:
197            return (0, f"{int(name):05d}")
198        except ValueError:
199            return (1, name)
200
201    if cv_values:
202        # Emit ``<CVvalue>`` — that's the tag name JMRI Java writes and
203        # reads. Interop-safe: a file we write will round-trip through
204        # JMRI Java, and a file JMRI Java wrote (with ``<CVvalue>``)
205        # gets picked up by our reader too.
206        #
207        # lxml's ``pretty_print`` only formats truly-new subtrees; when
208        # we append into an already-parsed element with existing
209        # whitespace nodes it silently packs siblings onto one line.
210        # Explicitly set ``.tail`` on each new entry to a newline +
211        # indent so the file stays hand-editable and diff-friendly.
212        # The last entry gets the closing-tag indent so ``</values>``
213        # lines up under its parent.
214        parent_depth = 0
215        node = values_el
216        while node is not None and node.tag != "locomotive-config":
217            parent_depth += 1
218            node = node.getparent()
219        indent_unit = "  "
220        entry_indent = "\n" + indent_unit * (parent_depth + 1)
221        close_indent = "\n" + indent_unit * parent_depth
222        sorted_names = sorted(cv_values, key=_key)
223        # Make sure the last-existing child's tail (if any) leads into
224        # the first new entry cleanly.
225        if len(values_el) > 0:
226            values_el[-1].tail = entry_indent
227        for i, name in enumerate(sorted_names):
228            el = etree.SubElement(values_el, "CVvalue", attrib={
229                "name": name,
230                "value": str(cv_values[name]),
231            })
232            el.tail = (
233                close_indent if i == len(sorted_names) - 1 else entry_indent
234            )
235    elif len(values_el) == 0:
236        # No per-CV entries left and no other children (decoderDef etc.)
237        # → drop the empty ``<values>`` element rather than leaving
238        # dead XML behind.
239        target_loco.remove(values_el)
240
241    tmp = p.with_suffix(p.suffix + ".tmp")
242    tree.write(
243        str(tmp),
244        xml_declaration=True,
245        encoding="utf-8",
246        pretty_print=True,
247        standalone=False,
248    )
249    tmp.replace(p)
250
251
252def load_cv_values_from_locomotive_config_bytes(
253    data: bytes, *, loco_id: str | None = None,
254) -> LocoCvState | None:
255    """Bytes-in variant of :func:`load_cv_values_from_locomotive_config`."""
256    parser = etree.XMLParser(
257        remove_blank_text=False,
258        resolve_entities=False,
259        load_dtd=False,
260        no_network=True,
261    )
262    try:
263        root = etree.fromstring(data, parser=parser)
264    except etree.XMLSyntaxError:
265        return None
266    if root.tag != "locomotive-config":
267        return None
268    for loco in root.iter("locomotive"):
269        this_id = loco.get("id", "")
270        if loco_id is not None and this_id != loco_id:
271            continue
272        values = loco.find("values")
273        if values is None:
274            return None
275        cv_values: dict[str, int] = {}
276        # Accept both element shapes:
277        # * ``<CVvalue name=... value=.../>`` — what real JMRI Java
278        #   writes inside ``<values>``. This is the JMRI-canonical form.
279        # * ``<CV name=... value=.../>`` — what earlier versions of our
280        #   own code wrote. Kept for backwards compat on files we've
281        #   already touched.
282        for tag in ("CVvalue", "CV"):
283            for cv in values.iter(tag):
284                name = cv.get("name")
285                val = cv.get("value")
286                if not name or val is None:
287                    continue
288                try:
289                    cv_values[name] = int(val)
290                except ValueError:
291                    continue
292        return LocoCvState(loco_id=this_id or (loco_id or ""), cv_values=cv_values)
293    return None
def load_loco_cv_bytes(data: bytes) -> jmri_core.beans.loco_cv_state.LocoCvState:
37def load_loco_cv_bytes(data: bytes) -> LocoCvState:
38    """Parse a ``rosters/<id>.xml`` from bytes."""
39    parser = etree.XMLParser(
40        remove_blank_text=False,
41        resolve_entities=False,
42        load_dtd=False,
43        no_network=True,
44    )
45    root = etree.fromstring(data, parser=parser)
46    if root.tag != "locomotive-cv-values":
47        raise ValueError(
48            f"expected <locomotive-cv-values> root, got <{root.tag}>"
49        )
50    loco_id = root.get("id") or ""
51    cv_values: dict[str, int] = {}
52    for cv in root.findall("CV"):
53        name = cv.get("name")
54        val = cv.get("value")
55        if not name or val is None:
56            continue
57        try:
58            cv_values[name] = int(val)
59        except ValueError:
60            # Skip malformed entries; don't blow up the rest of the file.
61            continue
62    return LocoCvState(loco_id=loco_id, cv_values=cv_values)

Parse a rosters/<id>.xml from bytes.

def load_loco_cv( path: str | pathlib._local.Path) -> jmri_core.beans.loco_cv_state.LocoCvState:
65def load_loco_cv(path: str | pathlib.Path) -> LocoCvState:
66    """Load a per-loco CV file. Returns an empty state if missing."""
67    p = pathlib.Path(path)
68    if not p.exists():
69        # The id is the file stem (e.g. ``459.xml`` → ``"459"``).
70        return LocoCvState(loco_id=p.stem)
71    return load_loco_cv_bytes(p.read_bytes())

Load a per-loco CV file. Returns an empty state if missing.

def save_loco_cv_bytes(state: jmri_core.beans.loco_cv_state.LocoCvState) -> bytes:
74def save_loco_cv_bytes(state: LocoCvState) -> bytes:
75    """Serialize a :class:`LocoCvState` to XML."""
76    root = etree.Element("locomotive-cv-values", attrib={"id": state.loco_id})
77    # Sort by numeric CV when possible, fall back to lexicographic.
78    def _key(name: str) -> tuple[int, str]:
79        try:
80            return (0, f"{int(name):05d}")
81        except ValueError:
82            return (1, name)
83    for name in sorted(state.cv_values, key=_key):
84        etree.SubElement(root, "CV", attrib={
85            "name": name,
86            "value": str(state.cv_values[name]),
87        })
88    return etree.tostring(
89        root,
90        xml_declaration=True,
91        encoding="utf-8",
92        pretty_print=True,
93        standalone=False,
94    )

Serialize a LocoCvState to XML.

def save_loco_cv( path: str | pathlib._local.Path, state: jmri_core.beans.loco_cv_state.LocoCvState) -> None:
 97def save_loco_cv(path: str | pathlib.Path, state: LocoCvState) -> None:
 98    target = pathlib.Path(path)
 99    target.parent.mkdir(parents=True, exist_ok=True)
100    # Atomic write: tmp + rename so a crash mid-write can't corrupt the file.
101    tmp = target.with_suffix(target.suffix + ".tmp")
102    tmp.write_bytes(save_loco_cv_bytes(state))
103    tmp.replace(target)
def load_cv_values_from_locomotive_config( path: str | pathlib._local.Path, *, loco_id: str | None = None) -> jmri_core.beans.loco_cv_state.LocoCvState | None:
116def load_cv_values_from_locomotive_config(
117    path: str | pathlib.Path, *, loco_id: str | None = None,
118) -> LocoCvState | None:
119    """Extract CV state from a ``locomotive-config`` file's ``<values>`` block.
120
121    The JMRI-canonical location for per-loco CV state. Returns ``None`` if
122    the file doesn't exist or has no ``<values>`` block; returns an
123    (empty) :class:`LocoCvState` when the block is present but has no
124    ``<CV>`` rows yet.
125
126    *loco_id* narrows the search when the file lists multiple
127    ``<locomotive>`` entries (rare — JMRI writes one per file). When
128    omitted, the first ``<locomotive>`` wins.
129    """
130    p = pathlib.Path(path)
131    if not p.exists():
132        return None
133    return load_cv_values_from_locomotive_config_bytes(
134        p.read_bytes(), loco_id=loco_id,
135    )

Extract CV state from a locomotive-config file's <values> block.

The JMRI-canonical location for per-loco CV state. Returns None if the file doesn't exist or has no <values> block; returns an (empty) LocoCvState when the block is present but has no <CV> rows yet.

loco_id narrows the search when the file lists multiple <locomotive> entries (rare — JMRI writes one per file). When omitted, the first <locomotive> wins.

def save_cv_values_into_locomotive_config( path: str | pathlib._local.Path, cv_values: dict[str, int], *, loco_id: str | None = None) -> None:
138def save_cv_values_into_locomotive_config(
139    path: str | pathlib.Path,
140    cv_values: dict[str, int],
141    *,
142    loco_id: str | None = None,
143) -> None:
144    """Update or insert a ``<values>`` block inside a ``locomotive-config``
145    file, preserving every other element.
146
147    * If *path* doesn't exist, does nothing (caller is expected to have
148      created the roster entry first via the roster service).
149    * If *cv_values* is empty, an existing ``<values>`` block is removed
150      entirely — the file still round-trips through the roster loader.
151    * When the file lists multiple locomotives (rare), *loco_id* picks
152      which one gets the update; otherwise the first is used.
153
154    Writes are atomic (tmp + rename) so a crash mid-write can't corrupt
155    the roster entry.
156    """
157    p = pathlib.Path(path)
158    if not p.exists():
159        return
160    parser = etree.XMLParser(
161        remove_blank_text=False,
162        resolve_entities=False,
163        load_dtd=False,
164        no_network=True,
165    )
166    tree = etree.parse(str(p), parser=parser)
167    root = tree.getroot()
168    if root.tag != "locomotive-config":
169        return
170    target_loco = None
171    for loco in root.iter("locomotive"):
172        if loco_id is None or loco.get("id", "") == loco_id:
173            target_loco = loco
174            break
175    if target_loco is None:
176        return
177
178    # Update in place: preserve any existing ``<values>`` sub-elements
179    # that AREN'T our per-CV entries. In particular JMRI Java writes a
180    # sibling ``<decoderDef>`` block inside ``<values>`` that tracks
181    # per-variable human values; wiping the whole ``<values>`` block
182    # (as an earlier revision did) destroys that data. Same story for
183    # any other unrecognised child element we don't own.
184    values_el = target_loco.find("values")
185    if values_el is None:
186        if not cv_values:
187            return
188        values_el = etree.SubElement(target_loco, "values")
189    else:
190        # Remove existing per-CV entries so we don't stack duplicates.
191        # Accept both element shapes we've written across versions.
192        for entry in list(values_el):
193            if isinstance(entry.tag, str) and entry.tag in ("CVvalue", "CV"):
194                values_el.remove(entry)
195
196    def _key(name: str) -> tuple[int, str]:
197        try:
198            return (0, f"{int(name):05d}")
199        except ValueError:
200            return (1, name)
201
202    if cv_values:
203        # Emit ``<CVvalue>`` — that's the tag name JMRI Java writes and
204        # reads. Interop-safe: a file we write will round-trip through
205        # JMRI Java, and a file JMRI Java wrote (with ``<CVvalue>``)
206        # gets picked up by our reader too.
207        #
208        # lxml's ``pretty_print`` only formats truly-new subtrees; when
209        # we append into an already-parsed element with existing
210        # whitespace nodes it silently packs siblings onto one line.
211        # Explicitly set ``.tail`` on each new entry to a newline +
212        # indent so the file stays hand-editable and diff-friendly.
213        # The last entry gets the closing-tag indent so ``</values>``
214        # lines up under its parent.
215        parent_depth = 0
216        node = values_el
217        while node is not None and node.tag != "locomotive-config":
218            parent_depth += 1
219            node = node.getparent()
220        indent_unit = "  "
221        entry_indent = "\n" + indent_unit * (parent_depth + 1)
222        close_indent = "\n" + indent_unit * parent_depth
223        sorted_names = sorted(cv_values, key=_key)
224        # Make sure the last-existing child's tail (if any) leads into
225        # the first new entry cleanly.
226        if len(values_el) > 0:
227            values_el[-1].tail = entry_indent
228        for i, name in enumerate(sorted_names):
229            el = etree.SubElement(values_el, "CVvalue", attrib={
230                "name": name,
231                "value": str(cv_values[name]),
232            })
233            el.tail = (
234                close_indent if i == len(sorted_names) - 1 else entry_indent
235            )
236    elif len(values_el) == 0:
237        # No per-CV entries left and no other children (decoderDef etc.)
238        # → drop the empty ``<values>`` element rather than leaving
239        # dead XML behind.
240        target_loco.remove(values_el)
241
242    tmp = p.with_suffix(p.suffix + ".tmp")
243    tree.write(
244        str(tmp),
245        xml_declaration=True,
246        encoding="utf-8",
247        pretty_print=True,
248        standalone=False,
249    )
250    tmp.replace(p)

Update or insert a <values> block inside a locomotive-config file, preserving every other element.

  • If path doesn't exist, does nothing (caller is expected to have created the roster entry first via the roster service).
  • If cv_values is empty, an existing <values> block is removed entirely — the file still round-trips through the roster loader.
  • When the file lists multiple locomotives (rare), loco_id picks which one gets the update; otherwise the first is used.

Writes are atomic (tmp + rename) so a crash mid-write can't corrupt the roster entry.

def load_cv_values_from_locomotive_config_bytes( data: bytes, *, loco_id: str | None = None) -> jmri_core.beans.loco_cv_state.LocoCvState | None:
253def load_cv_values_from_locomotive_config_bytes(
254    data: bytes, *, loco_id: str | None = None,
255) -> LocoCvState | None:
256    """Bytes-in variant of :func:`load_cv_values_from_locomotive_config`."""
257    parser = etree.XMLParser(
258        remove_blank_text=False,
259        resolve_entities=False,
260        load_dtd=False,
261        no_network=True,
262    )
263    try:
264        root = etree.fromstring(data, parser=parser)
265    except etree.XMLSyntaxError:
266        return None
267    if root.tag != "locomotive-config":
268        return None
269    for loco in root.iter("locomotive"):
270        this_id = loco.get("id", "")
271        if loco_id is not None and this_id != loco_id:
272            continue
273        values = loco.find("values")
274        if values is None:
275            return None
276        cv_values: dict[str, int] = {}
277        # Accept both element shapes:
278        # * ``<CVvalue name=... value=.../>`` — what real JMRI Java
279        #   writes inside ``<values>``. This is the JMRI-canonical form.
280        # * ``<CV name=... value=.../>`` — what earlier versions of our
281        #   own code wrote. Kept for backwards compat on files we've
282        #   already touched.
283        for tag in ("CVvalue", "CV"):
284            for cv in values.iter(tag):
285                name = cv.get("name")
286                val = cv.get("value")
287                if not name or val is None:
288                    continue
289                try:
290                    cv_values[name] = int(val)
291                except ValueError:
292                    continue
293        return LocoCvState(loco_id=this_id or (loco_id or ""), cv_values=cv_values)
294    return None