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    )
def turnout_mgr_class_for_prefix(prefix: str) -> str:
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.

def sensor_mgr_class_for_prefix(prefix: str) -> str:
97def sensor_mgr_class_for_prefix(prefix: str) -> str:
98    return _SENSOR_MGR_CLASS_BY_PREFIX.get(prefix, _DEFAULT_SENSOR_MGR_CLASS)
def light_mgr_class_for_prefix(prefix: str) -> str:
101def light_mgr_class_for_prefix(prefix: str) -> str:
102    return _LIGHT_MGR_CLASS_BY_PREFIX.get(prefix, _DEFAULT_LIGHT_MGR_CLASS)
@dataclass(slots=True)
class TurnoutRow:
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.

TurnoutRow( system_name: str, user_name: str | None = None, feedback: str | None = None, inverted: bool = False, automate: str | None = None, comment: str | None = None, extra_attribs: dict[str, str] = <factory>)
system_name: str
user_name: str | None
feedback: str | None
inverted: bool
automate: str | None
comment: str | None
extra_attribs: dict[str, str]
@dataclass(slots=True)
class SensorRow:
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.

SensorRow( system_name: str, user_name: str | None = None, inverted: bool = False, comment: str | None = None, extra_attribs: dict[str, str] = <factory>)
system_name: str
user_name: str | None
inverted: bool
comment: str | None
extra_attribs: dict[str, str]
@dataclass(slots=True)
class LightRow:
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.

LightRow( system_name: str, user_name: str | None = None, comment: str | None = None, extra_attribs: dict[str, str] = <factory>)
system_name: str
user_name: str | None
comment: str | None
extra_attribs: dict[str, str]
@dataclass(slots=True)
class TurnoutSection:
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.

TurnoutSection( manager_class: str = 'jmri.jmrix.internal.configurexml.InternalTurnoutManagerXml', rows: list[TurnoutRow] = <factory>, preserved_children: list[lxml.etree._Element] = <factory>)
manager_class: str
rows: list[TurnoutRow]
preserved_children: list[lxml.etree._Element]
@dataclass(slots=True)
class SensorSection:
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)
SensorSection( manager_class: str = 'jmri.jmrix.internal.configurexml.InternalSensorManagerXml', rows: list[SensorRow] = <factory>, preserved_children: list[lxml.etree._Element] = <factory>)
manager_class: str
rows: list[SensorRow]
preserved_children: list[lxml.etree._Element]
@dataclass(slots=True)
class LightSection:
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)
LightSection( manager_class: str = 'jmri.jmrix.internal.configurexml.InternalLightManagerXml', rows: list[LightRow] = <factory>, preserved_children: list[lxml.etree._Element] = <factory>)
manager_class: str
rows: list[LightRow]
preserved_children: list[lxml.etree._Element]
@dataclass
class ConfigFile:
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.

ConfigFile( root_attribs: dict[str, str] = <factory>, doctype: str | None = None, turnout_sections: list[TurnoutSection] = <factory>, sensor_sections: list[SensorSection] = <factory>, light_sections: list[LightSection] = <factory>, preserved: list[lxml.etree._Element] = <factory>)
root_attribs: dict[str, str]
doctype: str | None = None
turnout_sections: list[TurnoutSection]
sensor_sections: list[SensorSection]
light_sections: list[LightSection]
preserved: list[lxml.etree._Element]
def all_turnouts(self) -> Iterable[TurnoutRow]:
204    def all_turnouts(self) -> Iterable[TurnoutRow]:
205        for section in self.turnout_sections:
206            yield from section.rows
def all_sensors(self) -> Iterable[SensorRow]:
208    def all_sensors(self) -> Iterable[SensorRow]:
209        for section in self.sensor_sections:
210            yield from section.rows
def all_lights(self) -> Iterable[LightRow]:
212    def all_lights(self) -> Iterable[LightRow]:
213        for section in self.light_sections:
214            yield from section.rows
def load_config_bytes(data: bytes) -> ConfigFile:
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).

def load_config( path: str | pathlib._local.Path) -> ConfigFile:
374def load_config(path: str | pathlib.Path) -> ConfigFile:
375    return load_config_bytes(pathlib.Path(path).read_bytes())
def save_config_bytes(config: ConfigFile) -> bytes:
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).

def save_config( path: str | pathlib._local.Path, config: ConfigFile) -> None:
466def save_config(path: str | pathlib.Path, config: ConfigFile) -> None:
467    pathlib.Path(path).write_bytes(save_config_bytes(config))
def empty_config( *, doctype: str | None = None) -> ConfigFile:
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.