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
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.
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.
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.
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)
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.
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.
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
Bytes-in variant of load_cv_values_from_locomotive_config().