jmri_core.persistence.config_xml
<layout-config> XML — typed sections + opaque pass-through.
The JMRI 5.x bean-table file (PanelPro.xml on real installs;
Configuration.xml on the older shape) holds every persisted named
bean plus panels, blocks, LogixNG, etc. Modelling every section is
Stage 11 work. For alpha we ship typed load/save for the sections
operators most commonly edit through the web UI:
<turnouts>— system name, user name, feedback, inverted, automate, comment<sensors>— system name, user name, inverted, comment<lights>— system name, user name, comment
Every other top-level section (<signalheads>, <memories>,
<routes>, <blocks>, <paneleditor>, LogixNG, etc.) is
preserved verbatim as raw lxml.
XML shapes
JMRI 5.x writes bean rows with <systemName> (and optional
<userName> / <comment>) as child elements, with the
remaining bean attributes on the parent::
<turnout feedback="MONITORING" inverted="false" automate="Off">
<systemName>NT40</systemName>
<userName>Durango Staging turnout 1</userName>
<comment>Optional notes</comment>
</turnout>
Older JMRI installs (pre-5.x) wrote everything as parent attributes::
<turnout systemName="NT40" userName="..." feedback="MONITORING"/>
This module reads both forms but always writes the 5.x form for
parity with current JMRI installs. Unknown attributes on each bean row
are preserved verbatim through TurnoutRow.extra_attribs and friends.
1"""``<layout-config>`` XML — typed sections + opaque pass-through. 2 3The JMRI 5.x bean-table file (``PanelPro.xml`` on real installs; 4``Configuration.xml`` on the older shape) holds every persisted named 5bean plus panels, blocks, LogixNG, etc. Modelling every section is 6Stage 11 work. For alpha we ship typed load/save for the sections 7operators most commonly edit through the web UI: 8 9* ``<turnouts>`` — system name, user name, feedback, inverted, automate, comment 10* ``<sensors>`` — system name, user name, inverted, comment 11* ``<lights>`` — system name, user name, comment 12 13Every other top-level section (``<signalheads>``, ``<memories>``, 14``<routes>``, ``<blocks>``, ``<paneleditor>``, LogixNG, etc.) is 15preserved verbatim as raw lxml. 16 17## XML shapes 18 19JMRI 5.x writes bean rows with ``<systemName>`` (and optional 20``<userName>`` / ``<comment>``) as **child elements**, with the 21remaining bean attributes on the parent:: 22 23 <turnout feedback="MONITORING" inverted="false" automate="Off"> 24 <systemName>NT40</systemName> 25 <userName>Durango Staging turnout 1</userName> 26 <comment>Optional notes</comment> 27 </turnout> 28 29Older JMRI installs (pre-5.x) wrote everything as parent attributes:: 30 31 <turnout systemName="NT40" userName="..." feedback="MONITORING"/> 32 33This module **reads both forms** but **always writes the 5.x form** for 34parity with current JMRI installs. Unknown attributes on each bean row 35are preserved verbatim through ``TurnoutRow.extra_attribs`` and friends. 36""" 37from __future__ import annotations 38 39import pathlib 40from collections.abc import Iterable 41from dataclasses import dataclass, field 42 43from lxml import etree 44 45# JMRI's manager-class string format — used as the ``class`` attribute on 46# each `<turnouts>` / `<sensors>` etc. element. JMRI dispatches load/save 47# handlers based on this string, and its ``layout-*.xsd`` schema requires 48# it to match ``[a-zA-Z0-9.]*[a-z]`` (last char lowercase). The historical 49# abstract names like ``AbstractTurnoutManagerConfigXML`` end in uppercase 50# ``L`` and are schema-invalid — files with them fail to open in JMRI Java. 51 52# Default fallback classes for prefixes we don't know how to map. These 53# are the Internal (concrete) config-XML classes, both real and 54# schema-valid, so a file we emit stays loadable in JMRI Java even if we 55# don't recognise the connection driver. 56_DEFAULT_TURNOUT_MGR_CLASS = "jmri.jmrix.internal.configurexml.InternalTurnoutManagerXml" 57_DEFAULT_SENSOR_MGR_CLASS = "jmri.jmrix.internal.configurexml.InternalSensorManagerXml" 58_DEFAULT_LIGHT_MGR_CLASS = "jmri.jmrix.internal.configurexml.InternalLightManagerXml" 59 60# Map JMRI system-prefix single-letter code → concrete config-XML class 61# for each bean type. Values come from grepping JMRI Java's 62# ``jmri/jmrix/*/configurexml/*ManagerXml.java`` files. Extend as new 63# drivers get support. 64_TURNOUT_MGR_CLASS_BY_PREFIX = { 65 "I": "jmri.jmrix.internal.configurexml.InternalTurnoutManagerXml", 66 "N": "jmri.jmrix.nce.configurexml.NceTurnoutManagerXml", 67 "L": "jmri.jmrix.loconet.configurexml.LnTurnoutManagerXml", 68 "M": "jmri.jmrix.openlcb.configurexml.OlcbTurnoutManagerXml", 69 "D": "jmri.jmrix.dccpp.configurexml.DCCppTurnoutManagerXml", 70} 71_SENSOR_MGR_CLASS_BY_PREFIX = { 72 "I": "jmri.jmrix.internal.configurexml.InternalSensorManagerXml", 73 "N": "jmri.jmrix.nce.configurexml.NceSensorManagerXml", 74 "L": "jmri.jmrix.loconet.configurexml.LnSensorManagerXml", 75 "M": "jmri.jmrix.openlcb.configurexml.OlcbSensorManagerXml", 76 "D": "jmri.jmrix.dccpp.configurexml.DCCppSensorManagerXml", 77} 78_LIGHT_MGR_CLASS_BY_PREFIX = { 79 "I": "jmri.jmrix.internal.configurexml.InternalLightManagerXml", 80 "N": "jmri.jmrix.nce.configurexml.NceLightManagerXml", 81 "L": "jmri.jmrix.loconet.configurexml.LnLightManagerXml", 82 "M": "jmri.jmrix.openlcb.configurexml.OlcbLightManagerXml", 83 "D": "jmri.jmrix.dccpp.configurexml.DCCppLightManagerXml", 84} 85 86 87def turnout_mgr_class_for_prefix(prefix: str) -> str: 88 """Concrete ``AbstractTurnoutManagerConfigXML`` subclass name for *prefix*. 89 90 Falls back to the Internal class if we don't recognise the prefix — 91 that's still a real, schema-valid class name JMRI Java can load. 92 """ 93 return _TURNOUT_MGR_CLASS_BY_PREFIX.get(prefix, _DEFAULT_TURNOUT_MGR_CLASS) 94 95 96def sensor_mgr_class_for_prefix(prefix: str) -> str: 97 return _SENSOR_MGR_CLASS_BY_PREFIX.get(prefix, _DEFAULT_SENSOR_MGR_CLASS) 98 99 100def light_mgr_class_for_prefix(prefix: str) -> str: 101 return _LIGHT_MGR_CLASS_BY_PREFIX.get(prefix, _DEFAULT_LIGHT_MGR_CLASS) 102 103# Attributes we model on each bean row — read from XML, written explicitly. 104# Anything else (e.g. JMRI's ``locked`` / ``lockMode`` / ``decoder`` on 105# turnouts) is preserved verbatim in ``extra_attribs``. 106_TURNOUT_KNOWN_ATTRS = {"systemName", "userName", "feedback", "inverted", "automate"} 107_SENSOR_KNOWN_ATTRS = {"systemName", "userName", "inverted"} 108_LIGHT_KNOWN_ATTRS = {"systemName", "userName"} 109 110 111# ----- typed bean rows ------------------------------------------------------ 112 113 114@dataclass(slots=True) 115class TurnoutRow: 116 """One ``<turnout>`` row inside a ``<turnouts>`` section.""" 117 118 system_name: str 119 user_name: str | None = None 120 feedback: str | None = None # JMRI feedback mode name, e.g. "MONITORING" 121 inverted: bool = False 122 automate: str | None = None # JMRI turnout-operation name, e.g. "Default" 123 comment: str | None = None 124 # Unmodeled parent attributes (locked, lockMode, decoder, etc.) — kept 125 # verbatim so JMRI-authored details round-trip without us understanding 126 # them. 127 extra_attribs: dict[str, str] = field(default_factory=dict) 128 129 130@dataclass(slots=True) 131class SensorRow: 132 """One ``<sensor>`` row inside a ``<sensors>`` section.""" 133 134 system_name: str 135 user_name: str | None = None 136 inverted: bool = False 137 comment: str | None = None 138 extra_attribs: dict[str, str] = field(default_factory=dict) 139 140 141@dataclass(slots=True) 142class LightRow: 143 """One ``<light>`` row inside a ``<lights>`` section.""" 144 145 system_name: str 146 user_name: str | None = None 147 comment: str | None = None 148 extra_attribs: dict[str, str] = field(default_factory=dict) 149 150 151# ----- per-section containers ---------------------------------------------- 152 153 154@dataclass(slots=True) 155class TurnoutSection: 156 """A ``<turnouts class="...">`` block. JMRI groups by connection.""" 157 158 manager_class: str = _DEFAULT_TURNOUT_MGR_CLASS 159 rows: list[TurnoutRow] = field(default_factory=list) 160 # JMRI emits a ``<operations>`` child + per-direction defaults that we 161 # don't model — preserve them verbatim per-section so they round-trip. 162 preserved_children: list[etree._Element] = field(default_factory=list) 163 164 165@dataclass(slots=True) 166class SensorSection: 167 manager_class: str = _DEFAULT_SENSOR_MGR_CLASS 168 rows: list[SensorRow] = field(default_factory=list) 169 preserved_children: list[etree._Element] = field(default_factory=list) 170 171 172@dataclass(slots=True) 173class LightSection: 174 manager_class: str = _DEFAULT_LIGHT_MGR_CLASS 175 rows: list[LightRow] = field(default_factory=list) 176 preserved_children: list[etree._Element] = field(default_factory=list) 177 178 179# ----- full config file ---------------------------------------------------- 180 181 182@dataclass 183class ConfigFile: 184 """A parsed ``<layout-config>`` PanelPro.xml / Configuration.xml. 185 186 Typed sections (``turnout_sections``, ``sensor_sections``, 187 ``light_sections``) are lists because JMRI emits one element per 188 connection prefix. Other top-level elements (signal heads, memories, 189 routes, blocks, panels, LogixNG, etc.) live in ``preserved`` and are 190 written back verbatim. 191 """ 192 193 root_attribs: dict[str, str] = field(default_factory=dict) 194 doctype: str | None = None 195 turnout_sections: list[TurnoutSection] = field(default_factory=list) 196 sensor_sections: list[SensorSection] = field(default_factory=list) 197 light_sections: list[LightSection] = field(default_factory=list) 198 # Other top-level elements we don't model, kept verbatim: 199 preserved: list[etree._Element] = field(default_factory=list) 200 201 # ----- accessors ------------------------------------------------------- 202 203 def all_turnouts(self) -> Iterable[TurnoutRow]: 204 for section in self.turnout_sections: 205 yield from section.rows 206 207 def all_sensors(self) -> Iterable[SensorRow]: 208 for section in self.sensor_sections: 209 yield from section.rows 210 211 def all_lights(self) -> Iterable[LightRow]: 212 for section in self.light_sections: 213 yield from section.rows 214 215 216# ----- parse helpers -------------------------------------------------------- 217 218 219def _parse_bool(value: str | None, default: bool = False) -> bool: 220 """JMRI booleans are ``"true"``/``"false"`` or sometimes ``"yes"``/``"no"``.""" 221 if value is None: 222 return default 223 return value.strip().lower() in ("true", "yes", "1") 224 225 226def _child_text(elem: etree._Element, tag: str) -> str | None: 227 """Return the trimmed text of the first ``<tag>`` child, if any.""" 228 child = elem.find(tag) 229 if child is None or child.text is None: 230 return None 231 text = child.text.strip() 232 return text or None 233 234 235def _system_name_of(elem: etree._Element) -> str: 236 """Read the bean's system name from either the 5.x child element or 237 the legacy attribute. Returns the empty string when neither is set.""" 238 return _child_text(elem, "systemName") or elem.get("systemName") or "" 239 240 241def _user_name_of(elem: etree._Element) -> str | None: 242 return _child_text(elem, "userName") or elem.get("userName") or None 243 244 245def _comment_of(elem: etree._Element) -> str | None: 246 return _child_text(elem, "comment") or elem.get("comment") or None 247 248 249def _extra_attribs(elem: etree._Element, known: set[str]) -> dict[str, str]: 250 """Capture parent attributes we don't model so they can round-trip.""" 251 return {k: v for k, v in elem.attrib.items() if k not in known} 252 253 254# ----- parse: bean rows ----------------------------------------------------- 255 256 257def _parse_turnout(elem: etree._Element) -> TurnoutRow: 258 return TurnoutRow( 259 system_name=_system_name_of(elem), 260 user_name=_user_name_of(elem), 261 feedback=elem.get("feedback") or None, 262 inverted=_parse_bool(elem.get("inverted"), False), 263 automate=elem.get("automate") or None, 264 comment=_comment_of(elem), 265 extra_attribs=_extra_attribs(elem, _TURNOUT_KNOWN_ATTRS), 266 ) 267 268 269def _parse_sensor(elem: etree._Element) -> SensorRow: 270 return SensorRow( 271 system_name=_system_name_of(elem), 272 user_name=_user_name_of(elem), 273 inverted=_parse_bool(elem.get("inverted"), False), 274 comment=_comment_of(elem), 275 extra_attribs=_extra_attribs(elem, _SENSOR_KNOWN_ATTRS), 276 ) 277 278 279def _parse_light(elem: etree._Element) -> LightRow: 280 return LightRow( 281 system_name=_system_name_of(elem), 282 user_name=_user_name_of(elem), 283 comment=_comment_of(elem), 284 extra_attribs=_extra_attribs(elem, _LIGHT_KNOWN_ATTRS), 285 ) 286 287 288# ----- parse: sections ------------------------------------------------------ 289 290 291def _bean_row_children(elem: etree._Element, bean_tag: str) -> list[etree._Element]: 292 """Return the bean rows inside a section, in document order.""" 293 return [child for child in elem.findall(bean_tag) 294 if _system_name_of(child)] 295 296 297def _section_preserved(elem: etree._Element, bean_tag: str) -> list[etree._Element]: 298 """Section children that AREN'T bean rows (operations, defaults, …).""" 299 return [child for child in elem 300 if isinstance(child.tag, str) and child.tag != bean_tag] 301 302 303def _parse_turnout_section(elem: etree._Element) -> TurnoutSection: 304 return TurnoutSection( 305 manager_class=elem.get("class") or _DEFAULT_TURNOUT_MGR_CLASS, 306 rows=[_parse_turnout(t) for t in _bean_row_children(elem, "turnout")], 307 preserved_children=_section_preserved(elem, "turnout"), 308 ) 309 310 311def _parse_sensor_section(elem: etree._Element) -> SensorSection: 312 return SensorSection( 313 manager_class=elem.get("class") or _DEFAULT_SENSOR_MGR_CLASS, 314 rows=[_parse_sensor(s) for s in _bean_row_children(elem, "sensor")], 315 preserved_children=_section_preserved(elem, "sensor"), 316 ) 317 318 319def _parse_light_section(elem: etree._Element) -> LightSection: 320 return LightSection( 321 manager_class=elem.get("class") or _DEFAULT_LIGHT_MGR_CLASS, 322 rows=[_parse_light(l) for l in _bean_row_children(elem, "light")], 323 preserved_children=_section_preserved(elem, "light"), 324 ) 325 326 327# ----- parse: top-level ----------------------------------------------------- 328 329 330def load_config_bytes(data: bytes) -> ConfigFile: 331 """Parse a ``<layout-config>`` PanelPro/Configuration file from bytes. 332 333 ``remove_blank_text=True`` strips the whitespace-only text nodes 334 JMRI writes between siblings so lxml's ``pretty_print`` on save 335 produces cleanly indented output. Without this, freshly-added 336 ``<turnouts>`` / ``<sensors>`` sections come out on one gigantic 337 line — unreadable when a user tries to hand-edit the file. Safe 338 for us because the file is element-only (no mixed content that 339 would care about internal whitespace).""" 340 parser = etree.XMLParser( 341 remove_blank_text=True, 342 resolve_entities=False, 343 load_dtd=False, 344 no_network=True, 345 ) 346 root = etree.fromstring(data, parser=parser) 347 if root.tag != "layout-config": 348 raise ValueError( 349 f"expected <layout-config> root, got <{root.tag}>" 350 ) 351 tree = root.getroottree() 352 doctype: str | None = getattr(tree.docinfo, "doctype", None) or None 353 354 config = ConfigFile( 355 root_attribs={k: v for k, v in root.attrib.items()}, 356 doctype=doctype, 357 ) 358 for child in root: 359 if not isinstance(child.tag, str): 360 config.preserved.append(child) 361 continue 362 if child.tag == "turnouts": 363 config.turnout_sections.append(_parse_turnout_section(child)) 364 elif child.tag == "sensors": 365 config.sensor_sections.append(_parse_sensor_section(child)) 366 elif child.tag == "lights": 367 config.light_sections.append(_parse_light_section(child)) 368 else: 369 config.preserved.append(child) 370 return config 371 372 373def load_config(path: str | pathlib.Path) -> ConfigFile: 374 return load_config_bytes(pathlib.Path(path).read_bytes()) 375 376 377# ----- serialize: bean rows (JMRI 5.x shape) ------------------------------- 378 379 380def _set_child_text(parent: etree._Element, tag: str, text: str | None) -> None: 381 """Append ``<tag>text</tag>`` to *parent* iff *text* is set.""" 382 if text: 383 etree.SubElement(parent, tag).text = text 384 385 386def _write_turnout(parent: etree._Element, row: TurnoutRow) -> None: 387 attrs: dict[str, str] = dict(row.extra_attribs) 388 if row.feedback: 389 attrs["feedback"] = row.feedback 390 attrs["inverted"] = "true" if row.inverted else "false" 391 if row.automate: 392 attrs["automate"] = row.automate 393 elem = etree.SubElement(parent, "turnout", attrib=attrs) 394 _set_child_text(elem, "systemName", row.system_name) 395 _set_child_text(elem, "userName", row.user_name) 396 _set_child_text(elem, "comment", row.comment) 397 398 399def _write_sensor(parent: etree._Element, row: SensorRow) -> None: 400 attrs: dict[str, str] = dict(row.extra_attribs) 401 attrs["inverted"] = "true" if row.inverted else "false" 402 elem = etree.SubElement(parent, "sensor", attrib=attrs) 403 _set_child_text(elem, "systemName", row.system_name) 404 _set_child_text(elem, "userName", row.user_name) 405 _set_child_text(elem, "comment", row.comment) 406 407 408def _write_light(parent: etree._Element, row: LightRow) -> None: 409 attrs: dict[str, str] = dict(row.extra_attribs) 410 elem = etree.SubElement(parent, "light", attrib=attrs) 411 _set_child_text(elem, "systemName", row.system_name) 412 _set_child_text(elem, "userName", row.user_name) 413 _set_child_text(elem, "comment", row.comment) 414 415 416# ----- serialize: sections -------------------------------------------------- 417 418 419def _write_turnout_section(parent: etree._Element, section: TurnoutSection) -> None: 420 elem = etree.SubElement(parent, "turnouts", attrib={"class": section.manager_class}) 421 for child in section.preserved_children: 422 elem.append(child) 423 for row in section.rows: 424 _write_turnout(elem, row) 425 426 427def _write_sensor_section(parent: etree._Element, section: SensorSection) -> None: 428 elem = etree.SubElement(parent, "sensors", attrib={"class": section.manager_class}) 429 for child in section.preserved_children: 430 elem.append(child) 431 for row in section.rows: 432 _write_sensor(elem, row) 433 434 435def _write_light_section(parent: etree._Element, section: LightSection) -> None: 436 elem = etree.SubElement(parent, "lights", attrib={"class": section.manager_class}) 437 for child in section.preserved_children: 438 elem.append(child) 439 for row in section.rows: 440 _write_light(elem, row) 441 442 443def save_config_bytes(config: ConfigFile) -> bytes: 444 """Serialize a :class:`ConfigFile` back to XML bytes (JMRI 5.x shape).""" 445 root = etree.Element("layout-config", attrib=config.root_attribs) 446 for section in config.turnout_sections: 447 _write_turnout_section(root, section) 448 for section in config.sensor_sections: 449 _write_sensor_section(root, section) 450 for section in config.light_sections: 451 _write_light_section(root, section) 452 for elem in config.preserved: 453 root.append(elem) 454 common_kwargs: dict[str, object] = { 455 "xml_declaration": True, 456 "encoding": "utf-8", 457 "pretty_print": True, 458 "standalone": False, 459 } 460 if config.doctype: 461 common_kwargs["doctype"] = config.doctype 462 return etree.tostring(root, **common_kwargs) 463 464 465def save_config(path: str | pathlib.Path, config: ConfigFile) -> None: 466 pathlib.Path(path).write_bytes(save_config_bytes(config)) 467 468 469# ----- helpers for the typical "build a fresh config" path ----------------- 470 471 472def empty_config(*, doctype: str | None = None) -> ConfigFile: 473 """A blank :class:`ConfigFile` — useful when no file exists yet.""" 474 return ConfigFile( 475 root_attribs={}, 476 doctype=doctype or '<!DOCTYPE layout-config SYSTEM "layout-config.dtd">', 477 )
88def turnout_mgr_class_for_prefix(prefix: str) -> str: 89 """Concrete ``AbstractTurnoutManagerConfigXML`` subclass name for *prefix*. 90 91 Falls back to the Internal class if we don't recognise the prefix — 92 that's still a real, schema-valid class name JMRI Java can load. 93 """ 94 return _TURNOUT_MGR_CLASS_BY_PREFIX.get(prefix, _DEFAULT_TURNOUT_MGR_CLASS)
Concrete AbstractTurnoutManagerConfigXML subclass name for prefix.
Falls back to the Internal class if we don't recognise the prefix — that's still a real, schema-valid class name JMRI Java can load.
115@dataclass(slots=True) 116class TurnoutRow: 117 """One ``<turnout>`` row inside a ``<turnouts>`` section.""" 118 119 system_name: str 120 user_name: str | None = None 121 feedback: str | None = None # JMRI feedback mode name, e.g. "MONITORING" 122 inverted: bool = False 123 automate: str | None = None # JMRI turnout-operation name, e.g. "Default" 124 comment: str | None = None 125 # Unmodeled parent attributes (locked, lockMode, decoder, etc.) — kept 126 # verbatim so JMRI-authored details round-trip without us understanding 127 # them. 128 extra_attribs: dict[str, str] = field(default_factory=dict)
One <turnout> row inside a <turnouts> section.
131@dataclass(slots=True) 132class SensorRow: 133 """One ``<sensor>`` row inside a ``<sensors>`` section.""" 134 135 system_name: str 136 user_name: str | None = None 137 inverted: bool = False 138 comment: str | None = None 139 extra_attribs: dict[str, str] = field(default_factory=dict)
One <sensor> row inside a <sensors> section.
142@dataclass(slots=True) 143class LightRow: 144 """One ``<light>`` row inside a ``<lights>`` section.""" 145 146 system_name: str 147 user_name: str | None = None 148 comment: str | None = None 149 extra_attribs: dict[str, str] = field(default_factory=dict)
One <light> row inside a <lights> section.
155@dataclass(slots=True) 156class TurnoutSection: 157 """A ``<turnouts class="...">`` block. JMRI groups by connection.""" 158 159 manager_class: str = _DEFAULT_TURNOUT_MGR_CLASS 160 rows: list[TurnoutRow] = field(default_factory=list) 161 # JMRI emits a ``<operations>`` child + per-direction defaults that we 162 # don't model — preserve them verbatim per-section so they round-trip. 163 preserved_children: list[etree._Element] = field(default_factory=list)
A <turnouts class="..."> block. JMRI groups by connection.
166@dataclass(slots=True) 167class SensorSection: 168 manager_class: str = _DEFAULT_SENSOR_MGR_CLASS 169 rows: list[SensorRow] = field(default_factory=list) 170 preserved_children: list[etree._Element] = field(default_factory=list)
173@dataclass(slots=True) 174class LightSection: 175 manager_class: str = _DEFAULT_LIGHT_MGR_CLASS 176 rows: list[LightRow] = field(default_factory=list) 177 preserved_children: list[etree._Element] = field(default_factory=list)
183@dataclass 184class ConfigFile: 185 """A parsed ``<layout-config>`` PanelPro.xml / Configuration.xml. 186 187 Typed sections (``turnout_sections``, ``sensor_sections``, 188 ``light_sections``) are lists because JMRI emits one element per 189 connection prefix. Other top-level elements (signal heads, memories, 190 routes, blocks, panels, LogixNG, etc.) live in ``preserved`` and are 191 written back verbatim. 192 """ 193 194 root_attribs: dict[str, str] = field(default_factory=dict) 195 doctype: str | None = None 196 turnout_sections: list[TurnoutSection] = field(default_factory=list) 197 sensor_sections: list[SensorSection] = field(default_factory=list) 198 light_sections: list[LightSection] = field(default_factory=list) 199 # Other top-level elements we don't model, kept verbatim: 200 preserved: list[etree._Element] = field(default_factory=list) 201 202 # ----- accessors ------------------------------------------------------- 203 204 def all_turnouts(self) -> Iterable[TurnoutRow]: 205 for section in self.turnout_sections: 206 yield from section.rows 207 208 def all_sensors(self) -> Iterable[SensorRow]: 209 for section in self.sensor_sections: 210 yield from section.rows 211 212 def all_lights(self) -> Iterable[LightRow]: 213 for section in self.light_sections: 214 yield from section.rows
A parsed <layout-config> PanelPro.xml / Configuration.xml.
Typed sections (turnout_sections, sensor_sections,
light_sections) are lists because JMRI emits one element per
connection prefix. Other top-level elements (signal heads, memories,
routes, blocks, panels, LogixNG, etc.) live in preserved and are
written back verbatim.
331def load_config_bytes(data: bytes) -> ConfigFile: 332 """Parse a ``<layout-config>`` PanelPro/Configuration file from bytes. 333 334 ``remove_blank_text=True`` strips the whitespace-only text nodes 335 JMRI writes between siblings so lxml's ``pretty_print`` on save 336 produces cleanly indented output. Without this, freshly-added 337 ``<turnouts>`` / ``<sensors>`` sections come out on one gigantic 338 line — unreadable when a user tries to hand-edit the file. Safe 339 for us because the file is element-only (no mixed content that 340 would care about internal whitespace).""" 341 parser = etree.XMLParser( 342 remove_blank_text=True, 343 resolve_entities=False, 344 load_dtd=False, 345 no_network=True, 346 ) 347 root = etree.fromstring(data, parser=parser) 348 if root.tag != "layout-config": 349 raise ValueError( 350 f"expected <layout-config> root, got <{root.tag}>" 351 ) 352 tree = root.getroottree() 353 doctype: str | None = getattr(tree.docinfo, "doctype", None) or None 354 355 config = ConfigFile( 356 root_attribs={k: v for k, v in root.attrib.items()}, 357 doctype=doctype, 358 ) 359 for child in root: 360 if not isinstance(child.tag, str): 361 config.preserved.append(child) 362 continue 363 if child.tag == "turnouts": 364 config.turnout_sections.append(_parse_turnout_section(child)) 365 elif child.tag == "sensors": 366 config.sensor_sections.append(_parse_sensor_section(child)) 367 elif child.tag == "lights": 368 config.light_sections.append(_parse_light_section(child)) 369 else: 370 config.preserved.append(child) 371 return config
Parse a <layout-config> PanelPro/Configuration file from bytes.
remove_blank_text=True strips the whitespace-only text nodes
JMRI writes between siblings so lxml's pretty_print on save
produces cleanly indented output. Without this, freshly-added
<turnouts> / <sensors> sections come out on one gigantic
line — unreadable when a user tries to hand-edit the file. Safe
for us because the file is element-only (no mixed content that
would care about internal whitespace).
444def save_config_bytes(config: ConfigFile) -> bytes: 445 """Serialize a :class:`ConfigFile` back to XML bytes (JMRI 5.x shape).""" 446 root = etree.Element("layout-config", attrib=config.root_attribs) 447 for section in config.turnout_sections: 448 _write_turnout_section(root, section) 449 for section in config.sensor_sections: 450 _write_sensor_section(root, section) 451 for section in config.light_sections: 452 _write_light_section(root, section) 453 for elem in config.preserved: 454 root.append(elem) 455 common_kwargs: dict[str, object] = { 456 "xml_declaration": True, 457 "encoding": "utf-8", 458 "pretty_print": True, 459 "standalone": False, 460 } 461 if config.doctype: 462 common_kwargs["doctype"] = config.doctype 463 return etree.tostring(root, **common_kwargs)
Serialize a ConfigFile back to XML bytes (JMRI 5.x shape).
473def empty_config(*, doctype: str | None = None) -> ConfigFile: 474 """A blank :class:`ConfigFile` — useful when no file exists yet.""" 475 return ConfigFile( 476 root_attribs={}, 477 doctype=doctype or '<!DOCTYPE layout-config SYSTEM "layout-config.dtd">', 478 )
A blank ConfigFile — useful when no file exists yet.