jmri_core.persistence

XML persistence — load/save of JMRI configuration files.

Stage 1 shipped: per-loco locomotive-config round-trip (jmri_core.persistence.roster_xml).

Stage 2 adds:

  1"""XML persistence — load/save of JMRI configuration files.
  2
  3Stage 1 shipped: per-loco ``locomotive-config`` round-trip
  4(:mod:`jmri_core.persistence.roster_xml`).
  5
  6Stage 2 adds:
  7
  8* :mod:`jmri_core.persistence.profile` — ``Profile`` and the standard
  9  ``~/JMRI/`` directory layout.
 10* :mod:`jmri_core.persistence.roster_index_xml` — ``roster.xml`` index file
 11  (per ``roster.xsd``).
 12* :mod:`jmri_core.persistence.decoder_xml` — read-only decoder definition
 13  loader for the 881 ``xml/decoders/*.xml`` files.
 14* :mod:`jmri_core.persistence.operations_xml` — semantic round-trip for all
 15  six Operations module file families.
 16* :mod:`jmri_core.persistence.panel_xml` — envelope-level round-trip for
 17  PanelPro / LayoutEditor files (full element modeling is Stage 11).
 18"""
 19from jmri_core.beans.loco_cv_state import LocoCvState
 20from jmri_core.persistence.loco_cv_xml import (
 21    load_cv_values_from_locomotive_config,
 22    load_cv_values_from_locomotive_config_bytes,
 23    load_loco_cv,
 24    load_loco_cv_bytes,
 25    save_cv_values_into_locomotive_config,
 26    save_loco_cv,
 27    save_loco_cv_bytes,
 28)
 29from jmri_core.persistence.config_xml import (
 30    ConfigFile,
 31    LightRow,
 32    LightSection,
 33    SensorRow,
 34    SensorSection,
 35    TurnoutRow,
 36    TurnoutSection,
 37    empty_config,
 38    light_mgr_class_for_prefix,
 39    load_config,
 40    load_config_bytes,
 41    save_config,
 42    save_config_bytes,
 43    sensor_mgr_class_for_prefix,
 44    turnout_mgr_class_for_prefix,
 45)
 46from jmri_core.persistence.programmer_xml import (
 47    ProgrammerConfig,
 48    iter_programmer_configs,
 49    load_programmer_config,
 50    load_programmer_config_bytes,
 51)
 52from jmri_core.persistence.decoder_xml import (
 53    CompositeChoice,
 54    CompositeSetting,
 55    CvBitMask,
 56    DecoderFamily,
 57    DecoderFile,
 58    DecoderIndexEntry,
 59    DecoderModel,
 60    DecoderPane,
 61    DecoderVariable,
 62    EnumChoice,
 63    SplitCv,
 64    UnsupportedVariableKind,
 65    VariableKind,
 66    iter_decoder_files,
 67    iter_decoder_index,
 68    load_all_decoders,
 69    load_decoder,
 70    load_decoder_bytes,
 71    load_pane,
 72    load_pane_bytes,
 73    pane_layout_tree,
 74    find_composite_current_choice,
 75    resolve_composite_choice,
 76    VariableWriteOp,
 77    variable_apply_to_state,
 78    variable_cv_list,
 79    variable_extract_from_state,
 80    variable_extract_from_store,
 81    variable_is_indexed,
 82    variable_primary_cv,
 83    variable_store_key,
 84    variable_wire_read_plan,
 85    variable_wire_write_plan,
 86)
 87from jmri_core.persistence.operations_xml import (
 88    Car,
 89    Engine,
 90    Location,
 91    OperationsFile,
 92    Route,
 93    RouteLocation,
 94    Train,
 95    load_operations,
 96    load_operations_bytes,
 97    save_operations,
 98    save_operations_bytes,
 99)
100from jmri_core.persistence.panel_xml import (
101    PanelFile,
102    load_panel,
103    load_panel_bytes,
104    save_panel,
105    save_panel_bytes,
106)
107from jmri_core.persistence.profile import Profile, default_settings_dir
108from jmri_core.persistence.profile_registry import (
109    ACTIVE_MARKER,
110    PROFILES_XML,
111    ProfileRef,
112    ProfileRegistry,
113    load_profile_registry,
114    save_profile_registry,
115)
116from jmri_core.persistence.roster_index_xml import (
117    RosterGroup,
118    RosterIndex,
119    load_roster_index,
120    load_roster_index_bytes,
121    save_roster_index,
122    save_roster_index_bytes,
123)
124from jmri_core.persistence.roster_xml import (
125    load_locomotive,
126    load_locomotive_bytes,
127    save_locomotive,
128    save_locomotive_bytes,
129)
130
131__all__ = [
132    # Profile / directory layout
133    "Profile",
134    "default_settings_dir",
135    "ProfileRef",
136    "ProfileRegistry",
137    "ACTIVE_MARKER",
138    "PROFILES_XML",
139    "load_profile_registry",
140    "save_profile_registry",
141    # Per-loco CV state
142    "LocoCvState",
143    "load_cv_values_from_locomotive_config",
144    "load_cv_values_from_locomotive_config_bytes",
145    "load_loco_cv",
146    "load_loco_cv_bytes",
147    "save_cv_values_into_locomotive_config",
148    "save_loco_cv",
149    "save_loco_cv_bytes",
150    # layout-config (Configuration.xml)
151    "ConfigFile",
152    "LightRow",
153    "LightSection",
154    "SensorRow",
155    "SensorSection",
156    "TurnoutRow",
157    "TurnoutSection",
158    "empty_config",
159    "light_mgr_class_for_prefix",
160    "load_config",
161    "load_config_bytes",
162    "save_config",
163    "save_config_bytes",
164    "sensor_mgr_class_for_prefix",
165    "turnout_mgr_class_for_prefix",
166    # Per-loco roster
167    "load_locomotive",
168    "load_locomotive_bytes",
169    "save_locomotive",
170    "save_locomotive_bytes",
171    # Roster index
172    "RosterGroup",
173    "RosterIndex",
174    "load_roster_index",
175    "load_roster_index_bytes",
176    "save_roster_index",
177    "save_roster_index_bytes",
178    # Decoder defs
179    "CompositeChoice",
180    "CompositeSetting",
181    "CvBitMask",
182    "DecoderFamily",
183    "DecoderFile",
184    "DecoderIndexEntry",
185    "DecoderModel",
186    "DecoderPane",
187    "DecoderVariable",
188    "EnumChoice",
189    "SplitCv",
190    "UnsupportedVariableKind",
191    "VariableKind",
192    "iter_decoder_files",
193    "iter_decoder_index",
194    "load_all_decoders",
195    "load_decoder",
196    "load_decoder_bytes",
197    "load_pane",
198    "load_pane_bytes",
199    "pane_layout_tree",
200    "find_composite_current_choice",
201    "resolve_composite_choice",
202    "VariableWriteOp",
203    "variable_apply_to_state",
204    "variable_cv_list",
205    "variable_extract_from_state",
206    "variable_extract_from_store",
207    "variable_is_indexed",
208    "variable_primary_cv",
209    "variable_store_key",
210    "variable_wire_read_plan",
211    "variable_wire_write_plan",
212    # Programmer definitions (Comprehensive.xml, Basic.xml, ...)
213    "ProgrammerConfig",
214    "iter_programmer_configs",
215    "load_programmer_config",
216    "load_programmer_config_bytes",
217    # Operations
218    "Car",
219    "Engine",
220    "Location",
221    "OperationsFile",
222    "Route",
223    "RouteLocation",
224    "Train",
225    "load_operations",
226    "load_operations_bytes",
227    "save_operations",
228    "save_operations_bytes",
229    # Panel envelope
230    "PanelFile",
231    "load_panel",
232    "load_panel_bytes",
233    "save_panel",
234    "save_panel_bytes",
235]
@dataclass(slots=True)
class Profile:
 59@dataclass(slots=True)
 60class Profile:
 61    """One JMRI railroad profile.
 62
 63    Lives at ``<root>/<name>`` (e.g. ``~/JMRI/DRGW.jmri``). The bare name
 64    is whatever appears in ``profiles.xml`` — JMRI conventionally
 65    suffixes it with ``.jmri`` but we don't enforce that.
 66    """
 67
 68    root: pathlib.Path
 69    name: str = "default"
 70
 71    @classmethod
 72    def default(cls) -> Profile:
 73        """Construct using the platform-default settings directory."""
 74        return cls(root=default_settings_dir())
 75
 76    # ----- known paths ----------------------------------------------------
 77
 78    @property
 79    def profile_dir(self) -> pathlib.Path:
 80        """``<root>/<name>`` — the profile's root directory."""
 81        return self.root / self.name
 82
 83    @property
 84    def config_file(self) -> pathlib.Path:
 85        """The bean-tables file. Prefers ``PanelPro.xml`` (JMRI 5.x) if
 86        either it exists already; otherwise falls back to the older
 87        ``Configuration.xml``. Brand-new profiles get ``PanelPro.xml``.
 88        """
 89        panel = self.profile_dir / "PanelPro.xml"
 90        legacy = self.profile_dir / "Configuration.xml"
 91        if panel.exists():
 92            return panel
 93        if legacy.exists():
 94            return legacy
 95        return panel
 96
 97    @property
 98    def roster_dir(self) -> pathlib.Path:
 99        """Where per-loco ``locomotive-config`` XML files live."""
100        return self.profile_dir / "roster"
101
102    @property
103    def roster_index(self) -> pathlib.Path:
104        """Roster index — ``<profile>/roster.xml``, alongside the roster dir."""
105        return self.profile_dir / "roster.xml"
106
107    @property
108    def rosters_cv_dir(self) -> pathlib.Path:
109        """Per-loco CV state files (programmer output). Loaded on demand."""
110        return self.profile_dir / "rosters"
111
112    @property
113    def throttle_dir(self) -> pathlib.Path:
114        return self.profile_dir / "throttle"
115
116    @property
117    def signal_dir(self) -> pathlib.Path:
118        """Per-profile signal-mast definitions."""
119        return self.profile_dir / "signal"
120
121    @property
122    def programmers_dir(self) -> pathlib.Path:
123        return self.profile_dir / "programmers"
124
125    @property
126    def jython_dir(self) -> pathlib.Path:
127        """Legacy autoload dir (JMRI Java's Jython 2.7 tree). Kept as a
128        model attribute so imports from an older rewrite install still
129        parse — the autoloader no longer scans it. Use :attr:`python_dir`
130        for CPython 3 scripts."""
131        return self.profile_dir / "jython"
132
133    @property
134    def python_dir(self) -> pathlib.Path:
135        """Per-profile CPython 3 script dir.
136
137        Autoload sequence (see
138        :func:`jmri_server.app._autolaunch_profile_scripts`):
139
140        1. ``<settings-root>/python/`` — cross-profile shared scripts.
141        2. ``<profile>/python/`` — per-profile scripts.
142
143        Files ending in ``.py`` at the top level run in alphabetical
144        order; files starting with ``_`` are treated as private
145        modules that other scripts may import but aren't run standalone.
146        Missing directories are silently ignored — running without
147        either dir is a valid setup for a bare install.
148        """
149        return self.profile_dir / "python"
150
151    @property
152    def shared_python_dir(self) -> pathlib.Path:
153        """Cross-profile CPython 3 script dir at
154        ``<settings-root>/python/``. See :attr:`python_dir` for the
155        autoload sequence."""
156        return self.root / "python"
157
158    @property
159    def operations_dir(self) -> pathlib.Path:
160        return self.profile_dir / "operations"
161
162    @property
163    def panel_dir(self) -> pathlib.Path:
164        return self.profile_dir / "panels"
165
166    @property
167    def logixng_dir(self) -> pathlib.Path:
168        """Where LogixNG programs are stored as YAML files.
169
170        Each program lives at ``<profile>/logixng/<system_name>.yaml``
171        so a profile switch swaps the whole automation set atomically.
172        Directory is created on first save; loads treat a missing dir
173        as "no programs" rather than an error.
174        """
175        return self.profile_dir / "logixng"
176
177    @property
178    def log_dir(self) -> pathlib.Path:
179        return self.root / "log"
180
181    @property
182    def preferences_dir(self) -> pathlib.Path:
183        return self.profile_dir / "profile"
184
185    @property
186    def signal_systems_dir(self) -> pathlib.Path:
187        """User-defined signal systems shared across profiles."""
188        return self.root / "resources" / "signals"
189
190    # ----- helpers ---------------------------------------------------------
191
192    def ensure(self) -> None:
193        """Create every standard per-profile subdirectory if missing.
194
195        Idempotent. Does not touch the settings-root level directories
196        (those are shared across profiles).
197        """
198        for d in (
199            self.root,
200            self.profile_dir,
201            self.roster_dir,
202            self.rosters_cv_dir,
203            self.operations_dir,
204            self.panel_dir,
205            self.throttle_dir,
206            self.signal_dir,
207            self.log_dir,
208            self.signal_systems_dir,
209        ):
210            d.mkdir(parents=True, exist_ok=True)
211
212    def __repr__(self) -> str:
213        return f"Profile(name={self.name!r}, root={str(self.root)!r})"

One JMRI railroad profile.

Lives at <root>/<name> (e.g. ~/JMRI/DRGW.jmri). The bare name is whatever appears in profiles.xml — JMRI conventionally suffixes it with .jmri but we don't enforce that.

Profile(root: pathlib._local.Path, name: str = 'default')
root: pathlib._local.Path
name: str
@classmethod
def default(cls) -> Profile:
71    @classmethod
72    def default(cls) -> Profile:
73        """Construct using the platform-default settings directory."""
74        return cls(root=default_settings_dir())

Construct using the platform-default settings directory.

profile_dir: pathlib._local.Path
78    @property
79    def profile_dir(self) -> pathlib.Path:
80        """``<root>/<name>`` — the profile's root directory."""
81        return self.root / self.name

<root>/<name> — the profile's root directory.

config_file: pathlib._local.Path
83    @property
84    def config_file(self) -> pathlib.Path:
85        """The bean-tables file. Prefers ``PanelPro.xml`` (JMRI 5.x) if
86        either it exists already; otherwise falls back to the older
87        ``Configuration.xml``. Brand-new profiles get ``PanelPro.xml``.
88        """
89        panel = self.profile_dir / "PanelPro.xml"
90        legacy = self.profile_dir / "Configuration.xml"
91        if panel.exists():
92            return panel
93        if legacy.exists():
94            return legacy
95        return panel

The bean-tables file. Prefers PanelPro.xml (JMRI 5.x) if either it exists already; otherwise falls back to the older Configuration.xml. Brand-new profiles get PanelPro.xml.

roster_dir: pathlib._local.Path
 97    @property
 98    def roster_dir(self) -> pathlib.Path:
 99        """Where per-loco ``locomotive-config`` XML files live."""
100        return self.profile_dir / "roster"

Where per-loco locomotive-config XML files live.

roster_index: pathlib._local.Path
102    @property
103    def roster_index(self) -> pathlib.Path:
104        """Roster index — ``<profile>/roster.xml``, alongside the roster dir."""
105        return self.profile_dir / "roster.xml"

Roster index — <profile>/roster.xml, alongside the roster dir.

rosters_cv_dir: pathlib._local.Path
107    @property
108    def rosters_cv_dir(self) -> pathlib.Path:
109        """Per-loco CV state files (programmer output). Loaded on demand."""
110        return self.profile_dir / "rosters"

Per-loco CV state files (programmer output). Loaded on demand.

throttle_dir: pathlib._local.Path
112    @property
113    def throttle_dir(self) -> pathlib.Path:
114        return self.profile_dir / "throttle"
signal_dir: pathlib._local.Path
116    @property
117    def signal_dir(self) -> pathlib.Path:
118        """Per-profile signal-mast definitions."""
119        return self.profile_dir / "signal"

Per-profile signal-mast definitions.

programmers_dir: pathlib._local.Path
121    @property
122    def programmers_dir(self) -> pathlib.Path:
123        return self.profile_dir / "programmers"
jython_dir: pathlib._local.Path
125    @property
126    def jython_dir(self) -> pathlib.Path:
127        """Legacy autoload dir (JMRI Java's Jython 2.7 tree). Kept as a
128        model attribute so imports from an older rewrite install still
129        parse — the autoloader no longer scans it. Use :attr:`python_dir`
130        for CPython 3 scripts."""
131        return self.profile_dir / "jython"

Legacy autoload dir (JMRI Java's Jython 2.7 tree). Kept as a model attribute so imports from an older rewrite install still parse — the autoloader no longer scans it. Use python_dir for CPython 3 scripts.

python_dir: pathlib._local.Path
133    @property
134    def python_dir(self) -> pathlib.Path:
135        """Per-profile CPython 3 script dir.
136
137        Autoload sequence (see
138        :func:`jmri_server.app._autolaunch_profile_scripts`):
139
140        1. ``<settings-root>/python/`` — cross-profile shared scripts.
141        2. ``<profile>/python/`` — per-profile scripts.
142
143        Files ending in ``.py`` at the top level run in alphabetical
144        order; files starting with ``_`` are treated as private
145        modules that other scripts may import but aren't run standalone.
146        Missing directories are silently ignored — running without
147        either dir is a valid setup for a bare install.
148        """
149        return self.profile_dir / "python"

Per-profile CPython 3 script dir.

Autoload sequence (see jmri_server.app._autolaunch_profile_scripts()):

  1. <settings-root>/python/ — cross-profile shared scripts.
  2. <profile>/python/ — per-profile scripts.

Files ending in .py at the top level run in alphabetical order; files starting with _ are treated as private modules that other scripts may import but aren't run standalone. Missing directories are silently ignored — running without either dir is a valid setup for a bare install.

shared_python_dir: pathlib._local.Path
151    @property
152    def shared_python_dir(self) -> pathlib.Path:
153        """Cross-profile CPython 3 script dir at
154        ``<settings-root>/python/``. See :attr:`python_dir` for the
155        autoload sequence."""
156        return self.root / "python"

Cross-profile CPython 3 script dir at <settings-root>/python/. See python_dir for the autoload sequence.

operations_dir: pathlib._local.Path
158    @property
159    def operations_dir(self) -> pathlib.Path:
160        return self.profile_dir / "operations"
panel_dir: pathlib._local.Path
162    @property
163    def panel_dir(self) -> pathlib.Path:
164        return self.profile_dir / "panels"
logixng_dir: pathlib._local.Path
166    @property
167    def logixng_dir(self) -> pathlib.Path:
168        """Where LogixNG programs are stored as YAML files.
169
170        Each program lives at ``<profile>/logixng/<system_name>.yaml``
171        so a profile switch swaps the whole automation set atomically.
172        Directory is created on first save; loads treat a missing dir
173        as "no programs" rather than an error.
174        """
175        return self.profile_dir / "logixng"

Where LogixNG programs are stored as YAML files.

Each program lives at <profile>/logixng/<system_name>.yaml so a profile switch swaps the whole automation set atomically. Directory is created on first save; loads treat a missing dir as "no programs" rather than an error.

log_dir: pathlib._local.Path
177    @property
178    def log_dir(self) -> pathlib.Path:
179        return self.root / "log"
preferences_dir: pathlib._local.Path
181    @property
182    def preferences_dir(self) -> pathlib.Path:
183        return self.profile_dir / "profile"
signal_systems_dir: pathlib._local.Path
185    @property
186    def signal_systems_dir(self) -> pathlib.Path:
187        """User-defined signal systems shared across profiles."""
188        return self.root / "resources" / "signals"

User-defined signal systems shared across profiles.

def ensure(self) -> None:
192    def ensure(self) -> None:
193        """Create every standard per-profile subdirectory if missing.
194
195        Idempotent. Does not touch the settings-root level directories
196        (those are shared across profiles).
197        """
198        for d in (
199            self.root,
200            self.profile_dir,
201            self.roster_dir,
202            self.rosters_cv_dir,
203            self.operations_dir,
204            self.panel_dir,
205            self.throttle_dir,
206            self.signal_dir,
207            self.log_dir,
208            self.signal_systems_dir,
209        ):
210            d.mkdir(parents=True, exist_ok=True)

Create every standard per-profile subdirectory if missing.

Idempotent. Does not touch the settings-root level directories (those are shared across profiles).

def default_settings_dir() -> pathlib._local.Path:
35def default_settings_dir() -> pathlib.Path:
36    """OS-appropriate default settings directory matching JMRI's Java behavior.
37
38    Honours the ``JMRI_SETTINGS_DIR`` environment variable when set —
39    useful for pointing a Linux host at a Windows-formatted JMRI tree
40    (e.g. an SMB-mounted ``//Pi/JMRI`` or an rsync of the dev machine's
41    ``%USERPROFILE%\\JMRI``). The read goes through
42    :func:`jmri_core.env.settings_dir` so operator-facing env-var
43    names live in one registry.
44    """
45    from jmri_core.env import settings_dir  # noqa: PLC0415 — avoid an import cycle at module load
46    override = settings_dir()
47    if override is not None:
48        return override
49    system = platform.system()
50    if system == "Windows":
51        # JMRI installer creates %userprofile%\JMRI on Windows.
52        return pathlib.Path(os.environ.get("USERPROFILE", "~")).expanduser() / "JMRI"
53    if system == "Darwin":
54        return pathlib.Path("~/Library/Preferences/JMRI").expanduser()
55    # Linux + everything else.
56    return pathlib.Path("~/.jmri").expanduser()

OS-appropriate default settings directory matching JMRI's Java behavior.

Honours the JMRI_SETTINGS_DIR environment variable when set — useful for pointing a Linux host at a Windows-formatted JMRI tree (e.g. an SMB-mounted //Pi/JMRI or an rsync of the dev machine's %USERPROFILE%\JMRI). The read goes through jmri_core.env.settings_dir() so operator-facing env-var names live in one registry.

@dataclass(slots=True)
class ProfileRef:
40@dataclass(slots=True)
41class ProfileRef:
42    """One ``<profile>`` entry from ``profiles.xml``."""
43
44    id: str
45    path: str   # raw ``path`` attribute, possibly ``settings:Foo.jmri/``
46    name: str   # derived display name — directory name stripped of suffix
47
48    def to_profile(self, settings_root: pathlib.Path) -> Profile:
49        """Resolve to a runtime :class:`Profile` rooted at *settings_root*."""
50        dir_name = _strip_settings_prefix(self.path).rstrip("/").split("/")[-1]
51        if not dir_name:
52            raise ValueError(f"profile {self.id!r} has empty resolved path")
53        return Profile(root=settings_root, name=dir_name)

One <profile> entry from profiles.xml.

ProfileRef(id: str, path: str, name: str)
id: str
path: str
name: str
def to_profile( self, settings_root: pathlib._local.Path) -> Profile:
48    def to_profile(self, settings_root: pathlib.Path) -> Profile:
49        """Resolve to a runtime :class:`Profile` rooted at *settings_root*."""
50        dir_name = _strip_settings_prefix(self.path).rstrip("/").split("/")[-1]
51        if not dir_name:
52            raise ValueError(f"profile {self.id!r} has empty resolved path")
53        return Profile(root=settings_root, name=dir_name)

Resolve to a runtime Profile rooted at settings_root.

@dataclass
class ProfileRegistry:
 56@dataclass
 57class ProfileRegistry:
 58    """In-memory view of ``<settings>/profiles.xml`` and the active profile."""
 59
 60    settings_root: pathlib.Path
 61    profiles: list[ProfileRef]
 62
 63    # ----- accessors -------------------------------------------------------
 64
 65    def by_id(self, profile_id: str) -> ProfileRef | None:
 66        for p in self.profiles:
 67            if p.id == profile_id:
 68                return p
 69        return None
 70
 71    def by_name(self, name: str) -> ProfileRef | None:
 72        for p in self.profiles:
 73            if p.name == name:
 74                return p
 75        return None
 76
 77    def active(self) -> ProfileRef | None:
 78        """Return the currently-active profile, or ``None`` if unresolved.
 79
 80        Resolution order:
 81        1. ``<settings>/.active_profile`` marker file (our own bookkeeping).
 82        2. First profile in ``profiles.xml`` (JMRI's implicit default).
 83        """
 84        marker = self.settings_root / ACTIVE_MARKER
 85        if marker.exists():
 86            stored = marker.read_text(encoding="utf-8").strip()
 87            hit = self.by_id(stored) or self.by_name(stored)
 88            if hit:
 89                return hit
 90        return self.profiles[0] if self.profiles else None
 91
 92    def set_active(self, profile_id: str) -> None:
 93        """Write the ``.active_profile`` marker. Raises if id is unknown."""
 94        if self.by_id(profile_id) is None:
 95            raise KeyError(f"no profile with id {profile_id!r}")
 96        marker = self.settings_root / ACTIVE_MARKER
 97        marker.parent.mkdir(parents=True, exist_ok=True)
 98        marker.write_text(profile_id, encoding="utf-8")
 99
100    def active_profile(self) -> Profile | None:
101        """Convenience: resolved :class:`Profile` for the active entry."""
102        ref = self.active()
103        return ref.to_profile(self.settings_root) if ref else None

In-memory view of <settings>/profiles.xml and the active profile.

ProfileRegistry( settings_root: pathlib._local.Path, profiles: list[ProfileRef])
settings_root: pathlib._local.Path
profiles: list[ProfileRef]
def by_id( self, profile_id: str) -> ProfileRef | None:
65    def by_id(self, profile_id: str) -> ProfileRef | None:
66        for p in self.profiles:
67            if p.id == profile_id:
68                return p
69        return None
def by_name( self, name: str) -> ProfileRef | None:
71    def by_name(self, name: str) -> ProfileRef | None:
72        for p in self.profiles:
73            if p.name == name:
74                return p
75        return None
def active(self) -> ProfileRef | None:
77    def active(self) -> ProfileRef | None:
78        """Return the currently-active profile, or ``None`` if unresolved.
79
80        Resolution order:
81        1. ``<settings>/.active_profile`` marker file (our own bookkeeping).
82        2. First profile in ``profiles.xml`` (JMRI's implicit default).
83        """
84        marker = self.settings_root / ACTIVE_MARKER
85        if marker.exists():
86            stored = marker.read_text(encoding="utf-8").strip()
87            hit = self.by_id(stored) or self.by_name(stored)
88            if hit:
89                return hit
90        return self.profiles[0] if self.profiles else None

Return the currently-active profile, or None if unresolved.

Resolution order:

  1. <settings>/.active_profile marker file (our own bookkeeping).
  2. First profile in profiles.xml (JMRI's implicit default).
def set_active(self, profile_id: str) -> None:
92    def set_active(self, profile_id: str) -> None:
93        """Write the ``.active_profile`` marker. Raises if id is unknown."""
94        if self.by_id(profile_id) is None:
95            raise KeyError(f"no profile with id {profile_id!r}")
96        marker = self.settings_root / ACTIVE_MARKER
97        marker.parent.mkdir(parents=True, exist_ok=True)
98        marker.write_text(profile_id, encoding="utf-8")

Write the .active_profile marker. Raises if id is unknown.

def active_profile(self) -> Profile | None:
100    def active_profile(self) -> Profile | None:
101        """Convenience: resolved :class:`Profile` for the active entry."""
102        ref = self.active()
103        return ref.to_profile(self.settings_root) if ref else None

Convenience: resolved Profile for the active entry.

ACTIVE_MARKER = '.active_profile'
PROFILES_XML = 'profiles.xml'
def load_profile_registry( settings_root: pathlib._local.Path | None = None) -> ProfileRegistry:
129def load_profile_registry(settings_root: pathlib.Path | None = None) -> ProfileRegistry:
130    """Load ``profiles.xml`` from *settings_root* (default: platform default)."""
131    if settings_root is None:
132        settings_root = default_settings_dir()
133    path = settings_root / PROFILES_XML
134    if not path.exists():
135        return ProfileRegistry(settings_root=settings_root, profiles=[])
136    parser = etree.XMLParser(
137        remove_blank_text=False,
138        resolve_entities=False,
139        load_dtd=False,
140        no_network=True,
141    )
142    root = etree.fromstring(path.read_bytes(), parser=parser)
143    if root.tag != "profileConfig":
144        raise ValueError(
145            f"expected <profileConfig> root in {path}, got <{root.tag}>"
146        )
147    profiles_elem = root.find("profiles")
148    profiles: list[ProfileRef] = []
149    if profiles_elem is not None:
150        for child in profiles_elem.findall("profile"):
151            ref = _parse_profile_elem(child)
152            if ref is not None:
153                profiles.append(ref)
154    return ProfileRegistry(settings_root=settings_root, profiles=profiles)

Load profiles.xml from settings_root (default: platform default).

def save_profile_registry(registry: ProfileRegistry) -> None:
157def save_profile_registry(registry: ProfileRegistry) -> None:
158    """Write the registry back to ``<settings>/profiles.xml``.
159
160    Preserves a simple shape — we don't model ``<searchPaths>`` because
161    we never need to vary it. If a user's existing ``profiles.xml`` has
162    one, it gets dropped on save; this is acceptable for our alpha
163    since we don't create new profiles from the UI yet.
164    """
165    root = etree.Element("profileConfig")
166    profiles_elem = etree.SubElement(root, "profiles")
167    for ref in registry.profiles:
168        etree.SubElement(profiles_elem, "profile", attrib={
169            "id": ref.id,
170            "path": ref.path,
171        })
172    search = etree.SubElement(root, "searchPaths")
173    etree.SubElement(search, "path", attrib={
174        "path": "settings:",
175        "default": "true",
176    })
177    out = etree.tostring(
178        root,
179        xml_declaration=True,
180        encoding="utf-8",
181        pretty_print=True,
182        standalone=False,
183    )
184    registry.settings_root.mkdir(parents=True, exist_ok=True)
185    (registry.settings_root / PROFILES_XML).write_bytes(out)

Write the registry back to <settings>/profiles.xml.

Preserves a simple shape — we don't model <searchPaths> because we never need to vary it. If a user's existing profiles.xml has one, it gets dropped on save; this is acceptable for our alpha since we don't create new profiles from the UI yet.

class LocoCvState(pydantic.main.BaseModel):
19class LocoCvState(BaseModel):
20    """Decoder CV state for one locomotive.
21
22    Keys are CV names (strings, because JMRI uses ``"17.18"`` notation
23    for split CVs and similar non-integer cases). Values are byte
24    integers 0..255.
25    """
26
27    model_config = ConfigDict(populate_by_name=True)
28
29    loco_id: str
30    cv_values: dict[str, int] = Field(default_factory=dict)

Decoder CV state for one locomotive.

Keys are CV names (strings, because JMRI uses "17.18" notation for split CVs and similar non-integer cases). Values are byte integers 0..255.

loco_id: str = PydanticUndefined
cv_values: dict[str, int] = PydanticUndefined
def load_cv_values_from_locomotive_config( path: str | pathlib._local.Path, *, loco_id: str | None = None) -> 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 load_cv_values_from_locomotive_config_bytes( data: bytes, *, loco_id: str | None = None) -> 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
def load_loco_cv( path: str | pathlib._local.Path) -> 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 load_loco_cv_bytes(data: bytes) -> 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 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 save_loco_cv( path: str | pathlib._local.Path, 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 save_loco_cv_bytes(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.

@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
@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 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(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 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 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 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]
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.

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)
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 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 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 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 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 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 load_locomotive( path: str | pathlib._local.Path) -> list[jmri_core.beans.RosterEntry]:
56def load_locomotive(path: str | pathlib.Path) -> list[RosterEntry]:
57    """Parse a locomotive-config XML file path."""
58    p = pathlib.Path(path)
59    return load_locomotive_bytes(p.read_bytes())

Parse a locomotive-config XML file path.

def load_locomotive_bytes(data: bytes) -> list[jmri_core.beans.RosterEntry]:
49def load_locomotive_bytes(data: bytes) -> list[RosterEntry]:
50    """Parse a locomotive-config XML byte string into RosterEntry objects."""
51    parser = etree.XMLParser(remove_blank_text=False)
52    root = etree.fromstring(data, parser=parser)
53    return _parse_root(root)

Parse a locomotive-config XML byte string into RosterEntry objects.

def save_locomotive( path: str | pathlib._local.Path, entries: list[jmri_core.beans.RosterEntry]) -> None:
76def save_locomotive(path: str | pathlib.Path, entries: list[RosterEntry]) -> None:
77    pathlib.Path(path).write_bytes(save_locomotive_bytes(entries))
def save_locomotive_bytes(entries: list[jmri_core.beans.RosterEntry]) -> bytes:
62def save_locomotive_bytes(entries: list[RosterEntry]) -> bytes:
63    """Serialize *entries* to locomotive-config XML bytes."""
64    root = etree.Element("locomotive-config")
65    for e in entries:
66        root.append(_entry_to_element(e))
67    return etree.tostring(
68        root,
69        xml_declaration=True,
70        encoding="utf-8",
71        pretty_print=True,
72        standalone=False,
73    )

Serialize entries to locomotive-config XML bytes.

class RosterGroup(pydantic.main.BaseModel):
25class RosterGroup(BaseModel):
26    """A named group of roster entries (e.g. "freight power")."""
27    model_config = ConfigDict(populate_by_name=True)
28    name: str
29    members: list[str] = Field(default_factory=list)

A named group of roster entries (e.g. "freight power").

name: str = PydanticUndefined
members: list[str] = PydanticUndefined
class RosterIndex(pydantic.main.BaseModel):
32class RosterIndex(BaseModel):
33    """Top-level ``<roster-config>`` content."""
34    model_config = ConfigDict(arbitrary_types_allowed=True)
35    entries: list[RosterEntry] = Field(default_factory=list)
36    groups: list[RosterGroup] = Field(default_factory=list)
37    filter: str = ""

Top-level <roster-config> content.

entries: list[jmri_core.beans.RosterEntry] = PydanticUndefined
groups: list[RosterGroup] = PydanticUndefined
filter: str = ''
def load_roster_index( path: str | pathlib._local.Path) -> RosterIndex:
63def load_roster_index(path: str | pathlib.Path) -> RosterIndex:
64    return load_roster_index_bytes(pathlib.Path(path).read_bytes())
def load_roster_index_bytes(data: bytes) -> RosterIndex:
40def load_roster_index_bytes(data: bytes) -> RosterIndex:
41    parser = etree.XMLParser(remove_blank_text=False)
42    root = etree.fromstring(data, parser=parser)
43    if root.tag != "roster-config":
44        raise ValueError(f"expected <roster-config>, got <{root.tag}>")
45
46    roster_elem = root.find("roster")
47    entries = (
48        [_element_to_entry(loco) for loco in roster_elem.findall("locomotive")]
49        if roster_elem is not None
50        else []
51    )
52    filter_attr = roster_elem.get("filter", "") if roster_elem is not None else ""
53
54    groups: list[RosterGroup] = [
55        RosterGroup(name=(g.text or "").strip())
56        for rg in root.findall("rosterGroup")
57        for g in rg.findall("group")
58    ]
59
60    return RosterIndex(entries=entries, groups=groups, filter=filter_attr)
def save_roster_index( path: str | pathlib._local.Path, index: RosterIndex) -> None:
90def save_roster_index(path: str | pathlib.Path, index: RosterIndex) -> None:
91    pathlib.Path(path).write_bytes(save_roster_index_bytes(index))
def save_roster_index_bytes(index: RosterIndex) -> bytes:
67def save_roster_index_bytes(index: RosterIndex) -> bytes:
68    root = etree.Element("roster-config")
69    roster = etree.SubElement(root, "roster")
70    if index.filter:
71        roster.set("filter", index.filter)
72    for entry in index.entries:
73        roster.append(_entry_to_element(entry))
74
75    if index.groups:
76        rg = etree.SubElement(root, "rosterGroup")
77        for group in index.groups:
78            g = etree.SubElement(rg, "group")
79            g.text = group.name
80
81    return etree.tostring(
82        root,
83        xml_declaration=True,
84        encoding="utf-8",
85        pretty_print=True,
86        standalone=False,
87    )
class CompositeChoice(pydantic.main.BaseModel):
87class CompositeChoice(BaseModel):
88    """One ``<compositeChoice choice="X"><compositeSetting/>…</compositeChoice>``.
89
90    Composite variables are meta-variables — a user pick fans out into
91    multiple sub-variable writes. The write path needs the session-level
92    queue from step 4 to batch those atomically; step 2 just models the
93    structure.
94    """
95    choice: str
96    choice_by_lang: dict[str, str] = Field(default_factory=dict)
97    settings: list[CompositeSetting] = Field(default_factory=list)

One <compositeChoice choice="X"><compositeSetting/>…</compositeChoice>.

Composite variables are meta-variables — a user pick fans out into multiple sub-variable writes. The write path needs the session-level queue from step 4 to batch those atomically; step 2 just models the structure.

choice: str = PydanticUndefined
choice_by_lang: dict[str, str] = PydanticUndefined
settings: list[CompositeSetting] = PydanticUndefined
class CompositeSetting(pydantic.main.BaseModel):
77class CompositeSetting(BaseModel):
78    """One ``<compositeSetting label="X" value="Y"/>`` inside a compositeChoice.
79
80    ``label`` references another variable via its ``item`` name; picking
81    this composite choice writes ``value`` into that referenced variable.
82    """
83    label: str
84    value: int

One <compositeSetting label="X" value="Y"/> inside a compositeChoice.

label references another variable via its item name; picking this composite choice writes value into that referenced variable.

label: str = PydanticUndefined
value: int = PydanticUndefined
class CvBitMask(pydantic.main.BaseModel):
100class CvBitMask(BaseModel):
101    """Parsed ``<variable mask="XXVXXXXX">`` notation.
102
103    JMRI masks are written MSB → LSB with ``V`` marking bits this
104    variable owns and ``X`` marking don't-cares. ``"XXVXXXXX"`` means
105    bit 5 (``0b00100000``).
106
107    * ``shift`` — position of the LSB of the ``V``-region (from bit 0)
108    * ``width`` — number of ``V`` bits
109    * ``mask_value`` — the raw byte mask for the ``V``-region
110    """
111    model_config = ConfigDict(frozen=True)
112
113    raw: str = ""
114    shift: int = 0
115    width: int = 8
116
117    @classmethod
118    def parse(cls, s: str | None) -> CvBitMask:
119        """Turn a JMRI mask string into a structured mask.
120
121        Empty / all-``X`` / missing masks give a full-byte mask (shift=0,
122        width=8) — that's what JMRI treats as "no mask" and it means the
123        variable owns the whole byte.
124        """
125        if not s:
126            return cls(raw="", shift=0, width=8)
127        s = s.strip()
128        if not s or "V" not in s:
129            return cls(raw=s, shift=0, width=len(s) or 8)
130        total = len(s)
131        first_v = s.find("V")
132        last_v = s.rfind("V")
133        shift = total - 1 - last_v
134        width = last_v - first_v + 1
135        return cls(raw=s, shift=shift, width=width)
136
137    @property
138    def mask_value(self) -> int:
139        """The byte mask that isolates this variable's V-region."""
140        return ((1 << self.width) - 1) << self.shift
141
142    def extract(self, byte: int) -> int:
143        """Read this variable's value out of a raw byte."""
144        return (byte & self.mask_value) >> self.shift
145
146    def apply(self, byte: int, value: int) -> int:
147        """Stuff *value* into the V-region of *byte*, preserving other bits.
148
149        The read-modify-write step the caller performs before writing
150        back: fetch the current byte, call ``apply(byte, new_value)``,
151        write the resulting byte. Values wider than ``width`` bits are
152        truncated to the region size.
153        """
154        room = (1 << self.width) - 1
155        return (byte & ~self.mask_value) | ((value & room) << self.shift)

Parsed <variable mask="XXVXXXXX"> notation.

JMRI masks are written MSB → LSB with V marking bits this variable owns and X marking don't-cares. "XXVXXXXX" means bit 5 (0b00100000).

  • shift — position of the LSB of the V-region (from bit 0)
  • width — number of V bits
  • mask_value — the raw byte mask for the V-region
raw: str = ''
shift: int = 0
width: int = 8
@classmethod
def parse(cls, s: str | None) -> CvBitMask:
117    @classmethod
118    def parse(cls, s: str | None) -> CvBitMask:
119        """Turn a JMRI mask string into a structured mask.
120
121        Empty / all-``X`` / missing masks give a full-byte mask (shift=0,
122        width=8) — that's what JMRI treats as "no mask" and it means the
123        variable owns the whole byte.
124        """
125        if not s:
126            return cls(raw="", shift=0, width=8)
127        s = s.strip()
128        if not s or "V" not in s:
129            return cls(raw=s, shift=0, width=len(s) or 8)
130        total = len(s)
131        first_v = s.find("V")
132        last_v = s.rfind("V")
133        shift = total - 1 - last_v
134        width = last_v - first_v + 1
135        return cls(raw=s, shift=shift, width=width)

Turn a JMRI mask string into a structured mask.

Empty / all-X / missing masks give a full-byte mask (shift=0, width=8) — that's what JMRI treats as "no mask" and it means the variable owns the whole byte.

mask_value: int
137    @property
138    def mask_value(self) -> int:
139        """The byte mask that isolates this variable's V-region."""
140        return ((1 << self.width) - 1) << self.shift

The byte mask that isolates this variable's V-region.

def extract(self, byte: int) -> int:
142    def extract(self, byte: int) -> int:
143        """Read this variable's value out of a raw byte."""
144        return (byte & self.mask_value) >> self.shift

Read this variable's value out of a raw byte.

def apply(self, byte: int, value: int) -> int:
146    def apply(self, byte: int, value: int) -> int:
147        """Stuff *value* into the V-region of *byte*, preserving other bits.
148
149        The read-modify-write step the caller performs before writing
150        back: fetch the current byte, call ``apply(byte, new_value)``,
151        write the resulting byte. Values wider than ``width`` bits are
152        truncated to the region size.
153        """
154        room = (1 << self.width) - 1
155        return (byte & ~self.mask_value) | ((value & room) << self.shift)

Stuff value into the V-region of byte, preserving other bits.

The read-modify-write step the caller performs before writing back: fetch the current byte, call apply(byte, new_value), write the resulting byte. Values wider than width bits are truncated to the region size.

class DecoderFamily(pydantic.main.BaseModel):
311class DecoderFamily(BaseModel):
312    """One family containing one or more :class:`DecoderModel` entries."""
313    name: str
314    mfg: str = ""
315    models: list[DecoderModel] = Field(default_factory=list)
316    # Family-level ``<output>`` declarations. Some decoders (SoundTraxx
317    # Tsunami2, Digitrax, …) put ``<output name="N" label="…"/>`` after
318    # all the ``<model>`` elements so the labels apply to every model
319    # in the family. Callers that render function-map column headers
320    # should fall back here when a model has no outputs of its own.
321    outputs: list[DecoderOutput] = Field(default_factory=list)

One family containing one or more DecoderModel entries.

name: str = PydanticUndefined
mfg: str = ''
models: list[DecoderModel] = PydanticUndefined
outputs: list[jmri_core.persistence.decoder_xml.DecoderOutput] = PydanticUndefined
class DecoderFile(pydantic.main.BaseModel):
372class DecoderFile(BaseModel):
373    """One ``xml/decoders/*.xml`` file parsed into a model.
374
375    The ``raw_xml`` field is the original bytes (for byte-level operations);
376    individual element access is via the model attributes. ``includes``
377    captures any ``xi:include`` references for later resolution.
378    """
379    model_config = ConfigDict(arbitrary_types_allowed=True)
380
381    path: pathlib.Path | None = None
382    family: DecoderFamily | None = None
383    variables: list[DecoderVariable] = Field(default_factory=list)
384    # Decoder-declared programmer panes, in source order. Empty for
385    # decoder files that don't declare any — the programmer definition
386    # provides the standard tab set separately.
387    panes: list[DecoderPane] = Field(default_factory=list)
388    includes: list[str] = Field(default_factory=list)

One xml/decoders/*.xml file parsed into a model.

The raw_xml field is the original bytes (for byte-level operations); individual element access is via the model attributes. includes captures any xi:include references for later resolution.

path: pathlib._local.Path | None = None
family: DecoderFamily | None = None
variables: list[DecoderVariable] = PydanticUndefined
panes: list[DecoderPane] = PydanticUndefined
includes: list[str] = PydanticUndefined
class DecoderIndexEntry(pydantic.main.BaseModel):
593class DecoderIndexEntry(BaseModel):
594    """One (family, model) tuple + the file it lives in.
595
596    ``mfg`` comes from the ``<family mfg=…>`` attribute. Both ``family``
597    and ``mfg`` are shared across every entry from the same file; only
598    ``model`` varies. ``source_file`` is the path to the decoder XML —
599    caller reads it via :func:`load_decoder` when a full definition is
600    actually needed.
601
602    ``low_version_id`` / ``high_version_id`` bracket which CV7 values
603    this model accepts. Model-level ``lowVersionID``/``highVersionID``
604    override family-level defaults; both are optional so JMRI files that
605    don't declare them stay wildcard (0..255).
606
607    ``product_id`` is the ``<model productID="…"/>`` attribute — a
608    manufacturer-specific identifier that lets identify narrow past
609    the family. For SoundTraxx, ``productID`` is derived from CVs 253,
610    255, and 256 (see the identify route).
611    """
612    model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
613
614    family: str
615    mfg: str
616    model: str
617    source_file: pathlib.Path
618    low_version_id: int = 0
619    high_version_id: int = 255
620    product_id: str = ""

One (family, model) tuple + the file it lives in.

mfg comes from the <family mfg=…> attribute. Both family and mfg are shared across every entry from the same file; only model varies. source_file is the path to the decoder XML — caller reads it via load_decoder() when a full definition is actually needed.

low_version_id / high_version_id bracket which CV7 values this model accepts. Model-level lowVersionID/highVersionID override family-level defaults; both are optional so JMRI files that don't declare them stay wildcard (0..255).

product_id is the <model productID="…"/> attribute — a manufacturer-specific identifier that lets identify narrow past the family. For SoundTraxx, productID is derived from CVs 253, 255, and 256 (see the identify route).

family: str = PydanticUndefined
mfg: str = PydanticUndefined
model: str = PydanticUndefined
source_file: pathlib._local.Path = PydanticUndefined
low_version_id: int = 0
high_version_id: int = 255
product_id: str = ''
class DecoderModel(pydantic.main.BaseModel):
290class DecoderModel(BaseModel):
291    """One model within a family."""
292    model: str
293    connector: str = ""
294    show_empty_panes: str | None = None
295    max_input_volts: str | None = None
296    max_motor_current: str | None = None
297    outputs: list[DecoderOutput] = Field(default_factory=list)
298    protocols: list[str] = Field(default_factory=list)
299    size_length: str | None = None
300    size_width: str | None = None
301    size_height: str | None = None
302    size_units: str | None = None
303    # ``<model productID="…"/>`` — SoundTraxx, Digitrax, etc. use this
304    # to filter variables per specific decoder revision. When a
305    # ``<variable>`` carries ``include="pid1,pid2,…"`` or ``exclude="…"``,
306    # the UI shows/hides it based on whether this model's productID is
307    # in the list.
308    product_id: str = ""

One model within a family.

model: str = PydanticUndefined
connector: str = ''
show_empty_panes: str | None = None
max_input_volts: str | None = None
max_motor_current: str | None = None
outputs: list[jmri_core.persistence.decoder_xml.DecoderOutput] = PydanticUndefined
protocols: list[str] = PydanticUndefined
size_length: str | None = None
size_width: str | None = None
size_height: str | None = None
size_units: str | None = None
product_id: str = ''
class DecoderPane(pydantic.main.BaseModel):
324class DecoderPane(BaseModel):
325    """One JMRI programmer pane declared inside a decoder file.
326
327    Panes are the tabs in DecoderPro's programmer UI (Basic, Motor,
328    Sound, ...). A decoder file adds *extra* panes on top of the
329    standard set provided by the active programmer definition (which
330    lives in ``xml/programmers/*.xml`` — a separate loader).
331
332    ``items`` is the ordered list of variable ``item`` names referenced
333    by ``<display item="...">`` anywhere in the pane. Change-tracking
334    and pane-scoped read/write queues iterate this list.
335
336    ``raw_element`` preserves the underlying lxml element so a later
337    rendering step can reproduce the ``<column>``/``<row>``/``<label>``/
338    ``<separator>`` structure faithfully.
339    """
340    model_config = ConfigDict(arbitrary_types_allowed=True)
341
342    name: str = ""
343    # ``xml:lang`` → localised pane name. The default name (no lang
344    # attribute) stays in ``name``; other locales go here so a future
345    # UI can pick the user's language.
346    name_by_lang: dict[str, str] = Field(default_factory=dict)
347    # Ordered list of variable ``item`` names referenced by <display>
348    # elements anywhere inside the pane. Duplicates are preserved
349    # because JMRI's UI shows one row per <display>, not one row per
350    # unique item.
351    items: list[str] = Field(default_factory=list)
352    # ``<cvtable/>`` marker anywhere in the pane — the CVs tab that
353    # DecoderPro's "CVs" pane uses to expose raw CV read/write.
354    has_cvtable: bool = False
355    # ``<dccaddress/>`` marker — a first-class widget shown by the
356    # Basic pane at the top.
357    has_dcc_address: bool = False
358    # ``<speedtable/>`` widget marker. JMRI's Speed Table pane doesn't
359    # list per-step ``<display>`` items; instead the whole 28-step
360    # curve is drawn by this widget. When set, the render layer expands
361    # the decoder's SPEED_TABLE variable into per-step CV rows.
362    has_speed_table: bool = False
363    # ``<fnmapping/>`` marker — the Function Map pane's checkbox
364    # matrix. Like ``<cvtable/>``, it doesn't reference variables
365    # via ``<display>``; the renderer collects ``Fx controls output N``
366    # variables from the decoder's variable list.
367    has_fnmapping: bool = False
368    # Raw pane element for step-3 rich rendering.
369    raw_element: etree._Element | None = None

One JMRI programmer pane declared inside a decoder file.

Panes are the tabs in DecoderPro's programmer UI (Basic, Motor, Sound, ...). A decoder file adds extra panes on top of the standard set provided by the active programmer definition (which lives in xml/programmers/*.xml — a separate loader).

items is the ordered list of variable item names referenced by <display item="..."> anywhere in the pane. Change-tracking and pane-scoped read/write queues iterate this list.

raw_element preserves the underlying lxml element so a later rendering step can reproduce the <column>/<row>/<label>/ <separator> structure faithfully.

name: str = ''
name_by_lang: dict[str, str] = PydanticUndefined
items: list[str] = PydanticUndefined
has_cvtable: bool = False
has_dcc_address: bool = False
has_speed_table: bool = False
has_fnmapping: bool = False
raw_element: lxml.etree._Element | None = None
class DecoderVariable(pydantic.main.BaseModel):
222class DecoderVariable(BaseModel):
223    """One CV (or CV-group) variable as declared in ``<variables>``.
224
225    Backwards-compatible fields (``cv`` through ``ops_only``) stay
226    exactly as parsed in earlier stages. Step 2 adds:
227
228    * ``kind`` — which value-type child element the variable declared
229    * ``bit_mask`` — parsed :class:`CvBitMask` (never None; a variable
230      without a mask still has a full-byte mask)
231    * ``enum_choices`` — ordered choices for ENUM variables
232    * ``composite_choices`` — ordered choices for COMPOSITE variables
233    * ``split`` — parsed :class:`SplitCv` for SPLIT/SPLIT_HEX/etc
234    * ``constant_value`` — for the CONSTANT kind
235    * ``read_only`` / ``write_only`` — attribute flags
236    * ``tooltip`` — hover text
237    """
238    model_config = ConfigDict(populate_by_name=True)
239
240    cv: str = Field(alias="CV", default="")
241    item: str = ""
242    # ``<variable default="N">`` — the *human* default value the
243    # decoder ships with (for a masked-bit variable, the masked
244    # value; for a decimal variable, the whole byte). Parsed to an
245    # ``int`` at load time via :func:`_parse_default_int` so downstream
246    # consumers (default-overlay in the pane render, diff-filter in
247    # LocoCvStore, CV7 identity match) don't have to re-parse on
248    # every access. ``None`` when the attribute is absent or the value
249    # isn't a plain integer.
250    default: int | None = None
251    mask: str | None = None
252    min: str | None = None
253    max: str | None = None
254    label: str = ""
255    ops_only: bool = False
256
257    # step-2 additions
258    kind: VariableKind = VariableKind.UNKNOWN
259    bit_mask: CvBitMask = Field(default_factory=CvBitMask)
260    enum_choices: list[EnumChoice] = Field(default_factory=list)
261    composite_choices: list[CompositeChoice] = Field(default_factory=list)
262    split: SplitCv | None = None
263    constant_value: int | None = None
264    read_only: bool = False
265    write_only: bool = False
266    tooltip: str = ""
267    # SPEED_TABLE only: number of speed-step entries the table covers.
268    # NMRA standard is 28 (CV67..CV94); some decoders declare a 14-step
269    # or 128-step variant via ``<speedTable entries="N">``.
270    speed_table_entries: int = 28
271    # ``<variable include="pid1,pid2,…">`` / ``exclude="…"``: show this
272    # variable only for models whose ``productID`` is in the include
273    # list (or NOT in the exclude list). Empty means "no filter".
274    # SoundTraxx Tsunami2 uses this heavily so a single decoder file
275    # can serve dozens of model revisions: e.g. HEP Mode / Turbine
276    # Start-Stop / Stop Request Bell share a CV and take turns being
277    # applicable per productID.
278    include_product_ids: list[str] = Field(default_factory=list)
279    exclude_product_ids: list[str] = Field(default_factory=list)
280    # ``<qualifier><variableref>X</variableref><relation>Y</relation>
281    # <value>N</value></qualifier>`` — runtime visibility gate. All
282    # qualifiers must pass (AND) for the variable to be shown. Common
283    # relations: ``eq``, ``ne``, ``gt``, ``ge``, ``lt``, ``le``,
284    # ``exists``. SoundTraxx uses this to split a shared CV across
285    # multiple named variables depending on another CV's current
286    # value (HEP Mode / HEP Mode2 / Turbine Start-Stop pattern).
287    qualifiers: list[dict[str, str]] = Field(default_factory=list)

One CV (or CV-group) variable as declared in <variables>.

Backwards-compatible fields (cv through ops_only) stay exactly as parsed in earlier stages. Step 2 adds:

cv: str = ''
item: str = ''
default: int | None = None
mask: str | None = None
min: str | None = None
max: str | None = None
label: str = ''
ops_only: bool = False
kind: VariableKind = <VariableKind.UNKNOWN: 'unknown'>
bit_mask: CvBitMask = PydanticUndefined
enum_choices: list[EnumChoice] = PydanticUndefined
composite_choices: list[CompositeChoice] = PydanticUndefined
split: SplitCv | None = None
constant_value: int | None = None
read_only: bool = False
write_only: bool = False
tooltip: str = ''
speed_table_entries: int = 28
include_product_ids: list[str] = PydanticUndefined
exclude_product_ids: list[str] = PydanticUndefined
qualifiers: list[dict[str, str]] = PydanticUndefined
class EnumChoice(pydantic.main.BaseModel):
65class EnumChoice(BaseModel):
66    """One ``<enumChoice value="V">text</enumChoice>`` option.
67
68    JMRI allows the ``value`` attribute to be omitted, in which case the
69    positional index (0, 1, 2, …) is the wire value. The parser fills
70    that in so downstream code can treat all choices uniformly.
71    """
72    value: int
73    choice: str = ""
74    choice_by_lang: dict[str, str] = Field(default_factory=dict)

One <enumChoice value="V">text</enumChoice> option.

JMRI allows the value attribute to be omitted, in which case the positional index (0, 1, 2, …) is the wire value. The parser fills that in so downstream code can treat all choices uniformly.

value: int = PydanticUndefined
choice: str = ''
choice_by_lang: dict[str, str] = PydanticUndefined
class SplitCv(pydantic.main.BaseModel):
158class SplitCv(BaseModel):
159    """Split-CV addressing parsed from ``<variable CV="…">`` attribute strings.
160
161    Three JMRI-supported shapes:
162
163    * ``"1"`` — one CV, single byte
164    * ``"17.18"`` — long-address pattern: CV17 is the high byte
165      (with ``0xC0`` bits set), CV18 is the low byte
166    * ``"0.255.261"`` — bank/index/base indexed access (ESU-style)
167    * ``"0.255.261:4"`` — same as above, 4 consecutive bytes
168
169    ``cv_list`` gives the ordered CVs the variable touches, in
170    high-byte-first order (the read/write path iterates them).
171    """
172    model_config = ConfigDict(frozen=True)
173
174    cv_attr: str
175    primary_cv: int = 0
176    byte_count: int = 1
177    bank: int | None = None
178    index: int | None = None
179
180    @classmethod
181    def parse(cls, s: str) -> SplitCv:
182        if not s:
183            return cls(cv_attr="", primary_cv=0, byte_count=1)
184        s = s.strip()
185        if ":" in s:
186            head, count = s.split(":", 1)
187            try:
188                byte_count = max(1, int(count))
189            except ValueError:
190                byte_count = 1
191        else:
192            head = s
193            byte_count = 1
194        parts = head.split(".")
195        try:
196            nums = [int(p) for p in parts]
197        except ValueError:
198            return cls(cv_attr=s, primary_cv=0, byte_count=byte_count)
199        if len(nums) == 1:
200            return cls(cv_attr=s, primary_cv=nums[0], byte_count=byte_count)
201        if len(nums) == 2:
202            # Two adjacent CVs — the long-address pattern (CV17.CV18)
203            # or any two-byte pair.
204            return cls(
205                cv_attr=s, primary_cv=nums[0],
206                byte_count=max(byte_count, 2),
207            )
208        # 3+ parts: bank.index.base
209        return cls(
210            cv_attr=s,
211            bank=nums[0], index=nums[1],
212            primary_cv=nums[2],
213            byte_count=byte_count,
214        )
215
216    @property
217    def cv_list(self) -> list[int]:
218        """The CVs this variable touches, in high-byte-first order."""
219        return [self.primary_cv + i for i in range(self.byte_count)]

Split-CV addressing parsed from <variable CV="…"> attribute strings.

Three JMRI-supported shapes:

  • "1" — one CV, single byte
  • "17.18" — long-address pattern: CV17 is the high byte (with 0xC0 bits set), CV18 is the low byte
  • "0.255.261" — bank/index/base indexed access (ESU-style)
  • "0.255.261:4" — same as above, 4 consecutive bytes

cv_list gives the ordered CVs the variable touches, in high-byte-first order (the read/write path iterates them).

cv_attr: str = PydanticUndefined
primary_cv: int = 0
byte_count: int = 1
bank: int | None = None
index: int | None = None
@classmethod
def parse(cls, s: str) -> SplitCv:
180    @classmethod
181    def parse(cls, s: str) -> SplitCv:
182        if not s:
183            return cls(cv_attr="", primary_cv=0, byte_count=1)
184        s = s.strip()
185        if ":" in s:
186            head, count = s.split(":", 1)
187            try:
188                byte_count = max(1, int(count))
189            except ValueError:
190                byte_count = 1
191        else:
192            head = s
193            byte_count = 1
194        parts = head.split(".")
195        try:
196            nums = [int(p) for p in parts]
197        except ValueError:
198            return cls(cv_attr=s, primary_cv=0, byte_count=byte_count)
199        if len(nums) == 1:
200            return cls(cv_attr=s, primary_cv=nums[0], byte_count=byte_count)
201        if len(nums) == 2:
202            # Two adjacent CVs — the long-address pattern (CV17.CV18)
203            # or any two-byte pair.
204            return cls(
205                cv_attr=s, primary_cv=nums[0],
206                byte_count=max(byte_count, 2),
207            )
208        # 3+ parts: bank.index.base
209        return cls(
210            cv_attr=s,
211            bank=nums[0], index=nums[1],
212            primary_cv=nums[2],
213            byte_count=byte_count,
214        )
cv_list: list[int]
216    @property
217    def cv_list(self) -> list[int]:
218        """The CVs this variable touches, in high-byte-first order."""
219        return [self.primary_cv + i for i in range(self.byte_count)]

The CVs this variable touches, in high-byte-first order.

class UnsupportedVariableKind(builtins.ValueError):
1313class UnsupportedVariableKind(ValueError):
1314    """Raised when a caller tries to encode/decode a variable whose kind
1315    doesn't have a byte-level round-trip yet (COMPOSITE fans out to
1316    sub-variables; SPEED_TABLE has custom widget semantics)."""

Raised when a caller tries to encode/decode a variable whose kind doesn't have a byte-level round-trip yet (COMPOSITE fans out to sub-variables; SPEED_TABLE has custom widget semantics).

class VariableKind(enum.StrEnum):
45class VariableKind(StrEnum):
46    """The value-type child element declared inside a ``<variable>``.
47
48    ``UNKNOWN`` is the parser's fallback when a variable declares something
49    we don't model yet; downstream code treats it as an opaque byte.
50    """
51    DECIMAL         = "decimal"          # <decVal>
52    HEXADECIMAL     = "hexadecimal"      # <hexVal>
53    ENUM            = "enum"             # <enumVal>/<enumChoice>
54    SPEED_TABLE     = "speed_table"      # <speedTable>
55    SHORT_ADDRESS   = "short_address"    # <shortAddressVal> (CV1)
56    LONG_ADDRESS    = "long_address"     # <longAddressVal>  (CV17+CV18)
57    SPLIT           = "split"            # <splitVal>
58    SPLIT_HEX       = "split_hex"        # <splitHexVal>
59    SPLIT_DATETIME  = "split_datetime"   # <splitDateTimeVal>
60    COMPOSITE       = "composite"        # <compositeVal>
61    CONSTANT        = "constant"         # <constant>
62    UNKNOWN         = "unknown"

The value-type child element declared inside a <variable>.

UNKNOWN is the parser's fallback when a variable declares something we don't model yet; downstream code treats it as an opaque byte.

DECIMAL = <VariableKind.DECIMAL: 'decimal'>
HEXADECIMAL = <VariableKind.HEXADECIMAL: 'hexadecimal'>
ENUM = <VariableKind.ENUM: 'enum'>
SPEED_TABLE = <VariableKind.SPEED_TABLE: 'speed_table'>
SHORT_ADDRESS = <VariableKind.SHORT_ADDRESS: 'short_address'>
LONG_ADDRESS = <VariableKind.LONG_ADDRESS: 'long_address'>
SPLIT = <VariableKind.SPLIT: 'split'>
SPLIT_HEX = <VariableKind.SPLIT_HEX: 'split_hex'>
SPLIT_DATETIME = <VariableKind.SPLIT_DATETIME: 'split_datetime'>
COMPOSITE = <VariableKind.COMPOSITE: 'composite'>
CONSTANT = <VariableKind.CONSTANT: 'constant'>
UNKNOWN = <VariableKind.UNKNOWN: 'unknown'>
def iter_decoder_files( dir_path: str | pathlib._local.Path) -> Iterator[DecoderFile]:
552def iter_decoder_files(dir_path: str | pathlib.Path) -> Iterator[DecoderFile]:
553    """Walk *dir_path*, yielding one :class:`DecoderFile` per ``.xml``.
554
555    JMRI's ``xml/decoders/`` tree contains many non-``<decoder-config>``
556    XML files alongside the decoder definitions — NMRA CV lists, family
557    index files, function-label fragments included via xi:include, etc.
558    Anything whose root isn't ``<decoder-config>`` (raising ValueError
559    from :func:`_parse_decoder_root`) or whose XML is malformed is
560    skipped so the caller's enumeration completes across the whole tree
561    instead of aborting on the first non-decoder file.
562    """
563    root_path = pathlib.Path(dir_path)
564    for xml in sorted(root_path.rglob("*.xml")):
565        try:
566            yield load_decoder(xml)
567        except etree.XMLSyntaxError as e:
568            _log.debug("iter_decoder_files: XML parse failure %s: %s", xml, e)
569            continue
570        except ValueError as e:
571            # Non-decoder XML file (index/fragment/NMRA-CVs list). These
572            # live in the same tree by JMRI convention; just skip.
573            _log.debug("iter_decoder_files: skipping %s (%s)", xml, e)
574            continue
575        except OSError as e:
576            _log.warning("iter_decoder_files: cannot read %s: %s", xml, e)
577            continue

Walk dir_path, yielding one DecoderFile per .xml.

JMRI's xml/decoders/ tree contains many non-<decoder-config> XML files alongside the decoder definitions — NMRA CV lists, family index files, function-label fragments included via xi:include, etc. Anything whose root isn't <decoder-config> (raising ValueError from _parse_decoder_root()) or whose XML is malformed is skipped so the caller's enumeration completes across the whole tree instead of aborting on the first non-decoder file.

def iter_decoder_index( dir_path: str | pathlib._local.Path) -> Iterator[DecoderIndexEntry]:
623def iter_decoder_index(dir_path: str | pathlib.Path) -> Iterator[DecoderIndexEntry]:
624    """Yield one :class:`DecoderIndexEntry` per (family, model) pair.
625
626    A single decoder XML file typically declares one family with many
627    model rows, so one file emits multiple entries. Non-decoder XML
628    files under the same tree (NMRA CV lists, xi:include fragments,
629    programmer definitions) are skipped silently.
630
631    This function does *not* validate the file — it only touches the
632    ``<family>`` and ``<model>`` elements. Anything else in the file
633    (variables, panes, xi:includes) is ignored. Callers that need the
634    full definition should call :func:`load_decoder` on the returned
635    ``source_file`` path.
636    """
637    root_path = pathlib.Path(dir_path)
638    parser = etree.XMLParser(remove_blank_text=False, resolve_entities=False)
639    for xml in sorted(root_path.rglob("*.xml")):
640        try:
641            tree = etree.parse(str(xml), parser=parser)
642        except etree.XMLSyntaxError as e:
643            _log.debug("iter_decoder_index: XML parse failure %s: %s", xml, e)
644            continue
645        except OSError as e:
646            _log.warning("iter_decoder_index: cannot read %s: %s", xml, e)
647            continue
648        root = tree.getroot()
649        if root.tag != "decoder-config":
650            # Not a decoder file (index / programmer / fragment). Skip.
651            continue
652        # JMRI decoder files: one <family> element under <decoder>. Use
653        # descendant search because a few files nest differently and
654        # xi:include can add layers.
655        family_el = root.find(".//family")
656        if family_el is None:
657            continue
658        family_name = family_el.get("name", "").strip()
659        mfg = family_el.get("mfg", "").strip()
660        # Family-level version range — used as the default when a
661        # model doesn't declare its own. Missing attrs stay wildcard.
662        def _int_or(elem: "etree._Element", attr: str, fallback: int) -> int:
663            raw = elem.get(attr, "")
664            try:
665                return int(raw)
666            except (TypeError, ValueError):
667                return fallback
668        fam_low = _int_or(family_el, "lowVersionID", 0)
669        fam_high = _int_or(family_el, "highVersionID", 255)
670        for m in family_el.findall("model"):
671            model_name = m.get("model", "").strip()
672            if not model_name or not family_name:
673                # An empty family or model name would poison the index
674                # key. Skip; log at debug so a malformed file surfaces
675                # without spamming warnings on every load.
676                _log.debug(
677                    "iter_decoder_index: skipping model in %s: family=%r model=%r",
678                    xml, family_name, model_name,
679                )
680                continue
681            yield DecoderIndexEntry(
682                family=family_name, mfg=mfg,
683                model=model_name, source_file=xml,
684                low_version_id=_int_or(m, "lowVersionID", fam_low),
685                high_version_id=_int_or(m, "highVersionID", fam_high),
686                product_id=m.get("productID", "").strip(),
687            )

Yield one DecoderIndexEntry per (family, model) pair.

A single decoder XML file typically declares one family with many model rows, so one file emits multiple entries. Non-decoder XML files under the same tree (NMRA CV lists, xi:include fragments, programmer definitions) are skipped silently.

This function does not validate the file — it only touches the <family> and <model> elements. Anything else in the file (variables, panes, xi:includes) is ignored. Callers that need the full definition should call load_decoder() on the returned source_file path.

def load_all_decoders( dir_path: str | pathlib._local.Path) -> list[DecoderFile]:
580def load_all_decoders(dir_path: str | pathlib.Path) -> list[DecoderFile]:
581    return list(iter_decoder_files(dir_path))
def load_decoder( path: str | pathlib._local.Path, *, xml_root: pathlib._local.Path | None = None) -> DecoderFile:
419def load_decoder(
420    path: str | pathlib.Path,
421    *,
422    xml_root: pathlib.Path | None = None,
423) -> DecoderFile:
424    """Load and parse a decoder definition file.
425
426    When *xml_root* is omitted, it's inferred from *path* by walking
427    upward looking for a ``decoders/`` ancestor — the standard JMRI
428    layout is ``<xml_root>/decoders/<file>.xml``. If no ``decoders/``
429    ancestor is found, xi:include resolution is skipped (fragment paths
430    would be uncomputable).
431    """
432    p = pathlib.Path(path)
433    if xml_root is None:
434        xml_root = _infer_xml_root(p)
435    return load_decoder_bytes(p.read_bytes(), p, xml_root=xml_root)

Load and parse a decoder definition file.

When xml_root is omitted, it's inferred from path by walking upward looking for a decoders/ ancestor — the standard JMRI layout is <xml_root>/decoders/<file>.xml. If no decoders/ ancestor is found, xi:include resolution is skipped (fragment paths would be uncomputable).

def load_decoder_bytes( data: bytes, path: pathlib._local.Path | None = None, *, xml_root: pathlib._local.Path | None = None) -> DecoderFile:
395def load_decoder_bytes(
396    data: bytes,
397    path: pathlib.Path | None = None,
398    *,
399    xml_root: pathlib.Path | None = None,
400) -> DecoderFile:
401    """Parse decoder XML bytes, optionally resolving ``xi:include`` targets.
402
403    *xml_root* is the directory that ``http://jmri.org/xml/`` hrefs map
404    to on disk — typically ``<jmri install>/xml``. When supplied,
405    ``xi:include`` elements are resolved in-place before parsing, so
406    variables + panes that live in shared fragments (JMRI's TCS,
407    Digitrax, ESU decoders all do this) come through as real
408    :class:`DecoderVariable` / :class:`DecoderPane` entries instead of
409    silently vanishing. When *xml_root* is ``None``, xi:includes stay
410    untouched — matches the older no-op behaviour tests rely on.
411    """
412    parser = etree.XMLParser(remove_blank_text=False, resolve_entities=False)
413    root = etree.fromstring(data, parser=parser)
414    if xml_root is not None:
415        _resolve_xincludes(root, xml_root)
416    return _parse_decoder_root(root, path)

Parse decoder XML bytes, optionally resolving xi:include targets.

xml_root is the directory that http://jmri.org/xml/ hrefs map to on disk — typically <jmri install>/xml. When supplied, xi:include elements are resolved in-place before parsing, so variables + panes that live in shared fragments (JMRI's TCS, Digitrax, ESU decoders all do this) come through as real DecoderVariable / DecoderPane entries instead of silently vanishing. When xml_root is None, xi:includes stay untouched — matches the older no-op behaviour tests rely on.

def load_pane( path: str | pathlib._local.Path) -> DecoderPane:
548def load_pane(path: str | pathlib.Path) -> DecoderPane:
549    return load_pane_bytes(pathlib.Path(path).read_bytes())
def load_pane_bytes(data: bytes) -> DecoderPane:
534def load_pane_bytes(data: bytes) -> DecoderPane:
535    """Parse a standalone ``<pane>...</pane>`` fragment file.
536
537    JMRI ships reusable pane definitions under paths like
538    ``xml/decoders/digitrax/PaneDigitrax.xml`` with ``<pane>`` as the
539    root element. Decoder files pull them in via ``xi:include``.
540    """
541    parser = etree.XMLParser(remove_blank_text=False, resolve_entities=False)
542    root = etree.fromstring(data, parser=parser)
543    if root.tag != "pane":
544        raise ValueError(f"expected <pane> root, got <{root.tag}>")
545    return _parse_pane(root)

Parse a standalone <pane>...</pane> fragment file.

JMRI ships reusable pane definitions under paths like xml/decoders/digitrax/PaneDigitrax.xml with <pane> as the root element. Decoder files pull them in via xi:include.

def pane_layout_tree(pane: DecoderPane) -> dict:
795def pane_layout_tree(pane: "DecoderPane") -> dict:
796    """Build a nested tree describing the pane's authored layout.
797
798    Walks :attr:`DecoderPane.raw_element` and emits a tree of dicts
799    the renderer can iterate recursively. Recognises JMRI's usual
800    layout primitives — ``<column>``, ``<row>``, ``<group>``,
801    ``<display>``, ``<label>``, ``<separator>``, ``<cvtable>``,
802    ``<speedtable>``, ``<dccaddress>``.
803
804    Nodes carry a ``type`` key and either a ``children`` list
805    (containers) or type-specific attributes (leaves):
806
807    * ``{"type": "column", "children": [...]}``
808    * ``{"type": "display", "item": "Vstart"}``
809    * ``{"type": "label", "text": "Speed control"}``
810    * ``{"type": "separator"}``
811
812    Any element the renderer doesn't recognise (rare — hand-authored
813    decoder files sometimes have quirks) is silently skipped, keeping
814    the rest of the tree renderable.
815
816    When *pane* has no ``raw_element`` (e.g. constructed in a test
817    from a bare ``DecoderPane(name="X", items=[…])``), the returned
818    tree is a flat column of the pane's items — matches what the
819    legacy flat-table renderer showed and keeps tests uncoupled from
820    XML shape.
821    """
822    if pane.raw_element is None:
823        return _dedupe_display_nodes({
824            "type": "column",
825            "children": [
826                {"type": "display", "item": item}
827                for item in pane.items
828            ],
829        })
830    root_children = list(_layout_children(pane.raw_element))
831    # If the pane has no explicit container at the top level, wrap in
832    # a column so the renderer always starts from a container node.
833    if len(root_children) == 1 and root_children[0]["type"] in _LAYOUT_CONTAINER_TAGS:
834        return _dedupe_display_nodes(root_children[0])
835    return _dedupe_display_nodes(
836        {"type": "column", "children": root_children},
837    )

Build a nested tree describing the pane's authored layout.

Walks DecoderPane.raw_element and emits a tree of dicts the renderer can iterate recursively. Recognises JMRI's usual layout primitives — <column>, <row>, <group>, <display>, <label>, <separator>, <cvtable>, <speedtable>, <dccaddress>.

Nodes carry a type key and either a children list (containers) or type-specific attributes (leaves):

  • {"type": "column", "children": [...]}
  • {"type": "display", "item": "Vstart"}
  • {"type": "label", "text": "Speed control"}
  • {"type": "separator"}

Any element the renderer doesn't recognise (rare — hand-authored decoder files sometimes have quirks) is silently skipped, keeping the rest of the tree renderable.

When pane has no raw_element (e.g. constructed in a test from a bare DecoderPane(name="X", items=[…])), the returned tree is a flat column of the pane's items — matches what the legacy flat-table renderer showed and keeps tests uncoupled from XML shape.

def find_composite_current_choice( var: DecoderVariable, variables_by_item: dict[str, DecoderVariable], cv_bytes: dict[str, int]) -> str | None:
1618def find_composite_current_choice(
1619    var: DecoderVariable,
1620    variables_by_item: dict[str, DecoderVariable],
1621    cv_bytes: dict[str, int],
1622) -> str | None:
1623    """Find the composite choice whose settings match the current state.
1624
1625    Composite variables don't have a wire representation of their own —
1626    they're syntactic sugar for a group of sub-variable writes. So the
1627    "current choice" is whichever named preset's ``<compositeSetting>``
1628    values ALL match the corresponding sub-variables' current human
1629    values. If no choice matches (the sub-variables are in an
1630    intermediate state), returns ``None`` and the UI shows a blank /
1631    "custom" selection.
1632
1633    * ``variables_by_item`` — the decoder's full variable table keyed
1634      by item name. Composite settings reference sub-variables by
1635      item; this map lets us resolve them.
1636    * ``cv_bytes`` — string-keyed CV byte snapshot (same shape as
1637      :func:`variable_extract_from_store` consumes).
1638    """
1639    if var.kind is not VariableKind.COMPOSITE:
1640        return None
1641    for cc in var.composite_choices:
1642        matched = True
1643        for setting in cc.settings:
1644            target = variables_by_item.get(setting.label)
1645            if target is None:
1646                matched = False
1647                break
1648            try:
1649                current = variable_extract_from_store(target, cv_bytes)
1650            except UnsupportedVariableKind:
1651                matched = False
1652                break
1653            if current != setting.value:
1654                matched = False
1655                break
1656        if matched and cc.settings:
1657            return cc.choice
1658    return None

Find the composite choice whose settings match the current state.

Composite variables don't have a wire representation of their own — they're syntactic sugar for a group of sub-variable writes. So the "current choice" is whichever named preset's <compositeSetting> values ALL match the corresponding sub-variables' current human values. If no choice matches (the sub-variables are in an intermediate state), returns None and the UI shows a blank / "custom" selection.

  • variables_by_item — the decoder's full variable table keyed by item name. Composite settings reference sub-variables by item; this map lets us resolve them.
  • cv_bytes — string-keyed CV byte snapshot (same shape as variable_extract_from_store() consumes).
def resolve_composite_choice( var: DecoderVariable, choice_text: str) -> list[CompositeSetting]:
1591def resolve_composite_choice(
1592    var: DecoderVariable, choice_text: str,
1593) -> list[CompositeSetting]:
1594    """Return the ordered ``<compositeSetting>`` list for the named choice.
1595
1596    Composite variables fan out to sub-variable writes: picking a choice
1597    means writing each of its settings to the referenced sub-variables
1598    (looked up by ``item`` name). This helper does the choice → settings
1599    lookup; the caller (session/queue layer in step 4) is responsible
1600    for finding the referenced variables and applying their writes.
1601
1602    Raises ``KeyError`` if the choice text isn't a declared option.
1603    """
1604    if var.kind is not VariableKind.COMPOSITE:
1605        raise UnsupportedVariableKind(
1606            f"resolve_composite_choice: variable is {var.kind}, not COMPOSITE"
1607        )
1608    for cc in var.composite_choices:
1609        if cc.choice == choice_text:
1610            return list(cc.settings)
1611        if choice_text in cc.choice_by_lang.values():
1612            return list(cc.settings)
1613    raise KeyError(
1614        f"no composite choice named {choice_text!r} on variable {var.item!r}"
1615    )

Return the ordered <compositeSetting> list for the named choice.

Composite variables fan out to sub-variable writes: picking a choice means writing each of its settings to the referenced sub-variables (looked up by item name). This helper does the choice → settings lookup; the caller (session/queue layer in step 4) is responsible for finding the referenced variables and applying their writes.

Raises KeyError if the choice text isn't a declared option.

@dataclass(frozen=True)
class VariableWriteOp:
1377@dataclass(frozen=True)
1378class VariableWriteOp:
1379    """One physical CV write generated by :func:`variable_wire_write_plan`.
1380
1381    * ``wire_cv`` — actual CV number sent on the wire (bare integer;
1382      the DCC packet targets this CV under whatever bank/index the
1383      decoder currently has).
1384    * ``byte_value`` — 0..255 byte to write.
1385    * ``store_key`` — key to persist under in the roster's cv_values
1386      dict, or ``None`` when the op is bank/index setup (CV31/CV32)
1387      that shouldn't leave a per-write trace.
1388    """
1389    wire_cv: int
1390    byte_value: int
1391    store_key: str | None

One physical CV write generated by variable_wire_write_plan().

  • wire_cv — actual CV number sent on the wire (bare integer; the DCC packet targets this CV under whatever bank/index the decoder currently has).
  • byte_value — 0..255 byte to write.
  • store_key — key to persist under in the roster's cv_values dict, or None when the op is bank/index setup (CV31/CV32) that shouldn't leave a per-write trace.
VariableWriteOp(wire_cv: int, byte_value: int, store_key: str | None)
wire_cv: int
byte_value: int
store_key: str | None
def variable_apply_to_state( var: DecoderVariable, human_value: int, cv_bytes: dict[int, int]) -> dict[int, int]:
1548def variable_apply_to_state(
1549    var: DecoderVariable, human_value: int, cv_bytes: dict[int, int],
1550) -> dict[int, int]:
1551    """Compute the CV-byte writes needed to set the variable to *human_value*.
1552
1553    Returns ``{cv: new_byte_value}`` for every CV the write touches.
1554    Masked variables read the current byte out of *cv_bytes* to preserve
1555    the non-``V`` bits (read-modify-write); *cv_bytes* is the caller's
1556    running snapshot of committed values.
1557    """
1558    if var.kind is VariableKind.CONSTANT:
1559        return {}     # constants never write
1560    if var.read_only:
1561        return {}
1562    if var.kind is VariableKind.SHORT_ADDRESS:
1563        primary = variable_primary_cv(var)
1564        return {primary: human_value & 0x7F}
1565    if var.kind is VariableKind.LONG_ADDRESS:
1566        # NMRA: high byte carries the 0b11000000 long-address marker.
1567        addr = int(human_value) & 0x3FFF
1568        return {17: 0xC0 | ((addr >> 8) & 0x3F), 18: addr & 0xFF}
1569    if var.kind in (VariableKind.SPLIT, VariableKind.SPLIT_HEX):
1570        cvs = variable_cv_list(var)
1571        n = len(cvs)
1572        writes: dict[int, int] = {}
1573        v = int(human_value)
1574        # High-byte first, matching how ``extract`` reassembles.
1575        for idx, cv in enumerate(cvs):
1576            shift = 8 * (n - 1 - idx)
1577            writes[cv] = (v >> shift) & 0xFF
1578        return writes
1579    if var.kind in (
1580        VariableKind.DECIMAL, VariableKind.HEXADECIMAL,
1581        VariableKind.ENUM, VariableKind.UNKNOWN,
1582    ):
1583        primary = variable_primary_cv(var)
1584        current = cv_bytes.get(primary, 0)
1585        return {primary: var.bit_mask.apply(current, int(human_value))}
1586    raise UnsupportedVariableKind(
1587        f"variable_apply_to_state does not support {var.kind}"
1588    )

Compute the CV-byte writes needed to set the variable to human_value.

Returns {cv: new_byte_value} for every CV the write touches. Masked variables read the current byte out of cv_bytes to preserve the non-V bits (read-modify-write); cv_bytes is the caller's running snapshot of committed values.

def variable_cv_list(var: DecoderVariable) -> list[int]:
1335def variable_cv_list(var: DecoderVariable) -> list[int]:
1336    """Every CV byte this variable touches, low-address first.
1337
1338    * Single-byte variables → ``[primary_cv]``
1339    * Long-address → ``[17, 18]``
1340    * Split-CV — depends on ``byte_count`` on the parsed :class:`SplitCv`.
1341    """
1342    if var.kind is VariableKind.LONG_ADDRESS:
1343        return [17, 18]
1344    if var.split is not None:
1345        return var.split.cv_list
1346    primary = variable_primary_cv(var)
1347    return [primary] if primary else []

Every CV byte this variable touches, low-address first.

  • Single-byte variables → [primary_cv]
  • Long-address → [17, 18]
  • Split-CV — depends on byte_count on the parsed SplitCv.
def variable_extract_from_state( var: DecoderVariable, cv_bytes: dict[int, int]) -> int:
1509def variable_extract_from_state(
1510    var: DecoderVariable, cv_bytes: dict[int, int],
1511) -> int:
1512    """Decode the variable's *human value* from a snapshot of CV bytes.
1513
1514    * DECIMAL / HEXADECIMAL — the masked byte value
1515    * ENUM — the wire value (caller can look up the choice label)
1516    * SHORT_ADDRESS — CV1 straight through
1517    * LONG_ADDRESS — ``(CV17 & 0x3F) << 8 | CV18`` (strip the two long-
1518      address marker bits set on CV17)
1519    * SPLIT / SPLIT_HEX — big-endian assembly across ``cv_list``
1520    * CONSTANT — the pinned constant value
1521    * SPLIT_DATETIME, COMPOSITE, SPEED_TABLE — raise
1522      :class:`UnsupportedVariableKind`; those need step-specific work.
1523    """
1524    if var.kind is VariableKind.CONSTANT:
1525        return var.constant_value or 0
1526    if var.kind is VariableKind.SHORT_ADDRESS:
1527        return cv_bytes.get(variable_primary_cv(var), 0)
1528    if var.kind is VariableKind.LONG_ADDRESS:
1529        high = cv_bytes.get(17, 0) & 0x3F
1530        low = cv_bytes.get(18, 0) & 0xFF
1531        return (high << 8) | low
1532    if var.kind in (VariableKind.SPLIT, VariableKind.SPLIT_HEX):
1533        acc = 0
1534        for cv in variable_cv_list(var):
1535            acc = (acc << 8) | (cv_bytes.get(cv, 0) & 0xFF)
1536        return acc
1537    if var.kind in (
1538        VariableKind.DECIMAL, VariableKind.HEXADECIMAL,
1539        VariableKind.ENUM, VariableKind.UNKNOWN,
1540    ):
1541        byte = cv_bytes.get(variable_primary_cv(var), 0)
1542        return var.bit_mask.extract(byte)
1543    raise UnsupportedVariableKind(
1544        f"variable_extract_from_state does not support {var.kind}"
1545    )

Decode the variable's human value from a snapshot of CV bytes.

  • DECIMAL / HEXADECIMAL — the masked byte value
  • ENUM — the wire value (caller can look up the choice label)
  • SHORT_ADDRESS — CV1 straight through
  • LONG_ADDRESS — (CV17 & 0x3F) << 8 | CV18 (strip the two long- address marker bits set on CV17)
  • SPLIT / SPLIT_HEX — big-endian assembly across cv_list
  • CONSTANT — the pinned constant value
  • SPLIT_DATETIME, COMPOSITE, SPEED_TABLE — raise UnsupportedVariableKind; those need step-specific work.
def variable_extract_from_store( var: DecoderVariable, cv_values: dict[str, int]) -> int:
1472def variable_extract_from_store(
1473    var: DecoderVariable, cv_values: dict[str, int],
1474) -> int:
1475    """Like :func:`variable_extract_from_state` but keyed by store keys.
1476
1477    * Plain variables look up ``str(primary_cv)``.
1478    * Indexed variables look up ``"{bank}.{index}.{cv}"`` — the same
1479      shape JMRI Java writes in the roster's ``<CVvalue name="…">``.
1480    * Multi-byte splits assemble big-endian from consecutive keys.
1481
1482    Returns 0 when a key is missing (the caller should overlay a
1483    catalog default separately).
1484    """
1485    if var.kind is VariableKind.CONSTANT:
1486        return var.constant_value or 0
1487    if var.kind is VariableKind.LONG_ADDRESS:
1488        high = int(cv_values.get("17", 0)) & 0x3F
1489        low = int(cv_values.get("18", 0)) & 0xFF
1490        return (high << 8) | low
1491    if var.kind in (VariableKind.SPLIT, VariableKind.SPLIT_HEX):
1492        acc = 0
1493        for cv in variable_cv_list(var):
1494            key = variable_store_key(var, cv)
1495            acc = (acc << 8) | (int(cv_values.get(key, 0)) & 0xFF)
1496        return acc
1497    if var.kind in (
1498        VariableKind.DECIMAL, VariableKind.HEXADECIMAL,
1499        VariableKind.ENUM, VariableKind.UNKNOWN, VariableKind.SHORT_ADDRESS,
1500    ):
1501        key = variable_store_key(var, variable_primary_cv(var))
1502        byte = int(cv_values.get(key, 0))
1503        return var.bit_mask.extract(byte)
1504    raise UnsupportedVariableKind(
1505        f"variable_extract_from_store does not support {var.kind}"
1506    )

Like variable_extract_from_state() but keyed by store keys.

  • Plain variables look up str(primary_cv).
  • Indexed variables look up "{bank}.{index}.{cv}" — the same shape JMRI Java writes in the roster's <CVvalue name="…">.
  • Multi-byte splits assemble big-endian from consecutive keys.

Returns 0 when a key is missing (the caller should overlay a catalog default separately).

def variable_is_indexed(var: DecoderVariable) -> bool:
1350def variable_is_indexed(var: DecoderVariable) -> bool:
1351    """True when *var* uses ``bank.index.base`` indexed CV addressing.
1352
1353    Indexed access (Tsunami2 / ESU / newer NCE decoders) requires
1354    writing ``CV31=bank`` + ``CV32=index`` before every read or write
1355    to the base CV. The decoder maintains bank/index state internally
1356    and interprets subsequent CV257–CV511 accesses through that page.
1357    """
1358    return var.split is not None and var.split.bank is not None

True when var uses bank.index.base indexed CV addressing.

Indexed access (Tsunami2 / ESU / newer NCE decoders) requires writing CV31=bank + CV32=index before every read or write to the base CV. The decoder maintains bank/index state internally and interprets subsequent CV257–CV511 accesses through that page.

def variable_primary_cv(var: DecoderVariable) -> int:
1319def variable_primary_cv(var: DecoderVariable) -> int:
1320    """The primary CV byte this variable reads/writes.
1321
1322    For split variables the primary CV is the low-address byte of the
1323    split. For simple variables it's just ``int(var.cv)``. Returns 0
1324    if the CV attribute isn't a plain integer we can parse — the
1325    caller should treat that as "not directly programmable".
1326    """
1327    if var.split is not None:
1328        return var.split.primary_cv
1329    try:
1330        return int(var.cv)
1331    except ValueError:
1332        return 0

The primary CV byte this variable reads/writes.

For split variables the primary CV is the low-address byte of the split. For simple variables it's just int(var.cv). Returns 0 if the CV attribute isn't a plain integer we can parse — the caller should treat that as "not directly programmable".

def variable_store_key( var: DecoderVariable, cv_number: int) -> str:
1361def variable_store_key(var: DecoderVariable, cv_number: int) -> str:
1362    """Return the roster ``<CVvalue name="…">`` key for *cv_number* under *var*.
1363
1364    JMRI's roster file writes each CV under a distinct key:
1365
1366    * Plain CV: ``"275"``
1367    * Indexed CV: ``"16.1.275"`` — bank + index + base
1368
1369    So `16.1.275` and `275` coexist without colliding — they mean
1370    different CVs on the wire (same base but different decoder pages).
1371    """
1372    if variable_is_indexed(var):
1373        return f"{var.split.bank}.{var.split.index or 0}.{cv_number}"
1374    return str(cv_number)

Return the roster <CVvalue name="…"> key for cv_number under var.

JMRI's roster file writes each CV under a distinct key:

  • Plain CV: "275"
  • Indexed CV: "16.1.275" — bank + index + base

So 16.1.275 and 275 coexist without colliding — they mean different CVs on the wire (same base but different decoder pages).

def variable_wire_read_plan( var: DecoderVariable) -> list[VariableWriteOp]:
1428def variable_wire_read_plan(var: DecoderVariable) -> list[VariableWriteOp]:
1429    """Ordered wire ops to read *var*'s current bytes.
1430
1431    For plain variables: one entry per CV in :func:`variable_cv_list`.
1432    For indexed variables: ``CV31=bank`` + ``CV32=index`` setup ops
1433    (as writes with ``store_key=None``) then one read entry per base
1434    CV. Callers should send the setup writes and then read the
1435    ``wire_cv`` of any op whose ``store_key`` is non-None.
1436    """
1437    if variable_is_indexed(var):
1438        assert var.split is not None
1439        ops: list[VariableWriteOp] = [
1440            VariableWriteOp(wire_cv=31, byte_value=var.split.bank or 0, store_key=None),
1441            VariableWriteOp(wire_cv=32, byte_value=var.split.index or 0, store_key=None),
1442        ]
1443        for cv in variable_cv_list(var):
1444            ops.append(VariableWriteOp(
1445                wire_cv=cv, byte_value=0,   # unused for reads
1446                store_key=variable_store_key(var, cv),
1447            ))
1448        return ops
1449    # Non-indexed: no setup, just read each CV.
1450    return [
1451        VariableWriteOp(wire_cv=cv, byte_value=0, store_key=str(cv))
1452        for cv in variable_cv_list(var)
1453    ]

Ordered wire ops to read var's current bytes.

For plain variables: one entry per CV in variable_cv_list(). For indexed variables: CV31=bank + CV32=index setup ops (as writes with store_key=None) then one read entry per base CV. Callers should send the setup writes and then read the wire_cv of any op whose store_key is non-None.

def variable_wire_write_plan( var: DecoderVariable, human_value: int, cv_values: dict[str, int]) -> list[VariableWriteOp]:
1394def variable_wire_write_plan(
1395    var: DecoderVariable,
1396    human_value: int,
1397    cv_values: dict[str, int],
1398) -> list[VariableWriteOp]:
1399    """Ordered wire writes to set *var* to *human_value*.
1400
1401    Indexed variables produce three-or-more ops: ``CV31=bank``,
1402    ``CV32=index``, then one or more base-CV writes. The setup ops
1403    carry ``store_key=None`` so callers can skip persisting them
1404    (they'd corrupt the roster's per-variable tracking otherwise).
1405
1406    Non-indexed variables produce the same wire writes as
1407    :func:`variable_apply_to_state` but wrapped in :class:`VariableWriteOp`
1408    with plain string keys (``"275"``) for round-trip storage.
1409
1410    Read-only / CONSTANT variables return an empty list.
1411    """
1412    plain = variable_apply_to_state(var, human_value, _int_keyed(cv_values))
1413    if not plain:
1414        return []
1415    ops: list[VariableWriteOp] = []
1416    if variable_is_indexed(var):
1417        assert var.split is not None  # narrow for the type checker
1418        ops.append(VariableWriteOp(wire_cv=31, byte_value=var.split.bank or 0, store_key=None))
1419        ops.append(VariableWriteOp(wire_cv=32, byte_value=var.split.index or 0, store_key=None))
1420    for wire_cv, byte in plain.items():
1421        ops.append(VariableWriteOp(
1422            wire_cv=wire_cv, byte_value=byte,
1423            store_key=variable_store_key(var, wire_cv),
1424        ))
1425    return ops

Ordered wire writes to set var to human_value.

Indexed variables produce three-or-more ops: CV31=bank, CV32=index, then one or more base-CV writes. The setup ops carry store_key=None so callers can skip persisting them (they'd corrupt the roster's per-variable tracking otherwise).

Non-indexed variables produce the same wire writes as variable_apply_to_state() but wrapped in VariableWriteOp with plain string keys ("275") for round-trip storage.

Read-only / CONSTANT variables return an empty list.

class ProgrammerConfig(pydantic.main.BaseModel):
47class ProgrammerConfig(BaseModel):
48    """One loaded programmer definition (e.g. Comprehensive.xml).
49
50    * ``panes`` — the standard programmer panes in include order (Basic,
51      Motor, Basic Speed Control, ...). One entry per successful
52      ``xi:include`` resolution.
53    * ``unresolved_includes`` — hrefs that couldn't be resolved to a
54      local file, preserved for diagnostics. An unresolved include just
55      means that pane doesn't appear in the tab list; the rest of the
56      programmer still works.
57    """
58    model_config = ConfigDict(arbitrary_types_allowed=True)
59
60    name: str = ""
61    panes: list[DecoderPane] = Field(default_factory=list)
62    unresolved_includes: list[str] = Field(default_factory=list)
63    source_file: pathlib.Path | None = None
64    # ``<programmer decoderFilePanes="yes" showEmptyPanes="default">``
65    # attributes. ``decoderFilePanes="yes"`` means: also render any
66    # decoder-declared panes in addition to the standard ones.
67    # ``showEmptyPanes="default"`` = respect per-model showEmptyPanes;
68    # ``"yes"`` = always show; ``"no"`` = always hide.
69    decoder_file_panes: bool = True
70    show_empty_panes: str = "default"

One loaded programmer definition (e.g. Comprehensive.xml).

  • panes — the standard programmer panes in include order (Basic, Motor, Basic Speed Control, ...). One entry per successful xi:include resolution.
  • unresolved_includes — hrefs that couldn't be resolved to a local file, preserved for diagnostics. An unresolved include just means that pane doesn't appear in the tab list; the rest of the programmer still works.
name: str = ''
panes: list[DecoderPane] = PydanticUndefined
unresolved_includes: list[str] = PydanticUndefined
source_file: pathlib._local.Path | None = None
decoder_file_panes: bool = True
show_empty_panes: str = 'default'
def iter_programmer_configs( dir_path: str | pathlib._local.Path, *, xml_root: pathlib._local.Path | None = None) -> Iterator[ProgrammerConfig]:
152def iter_programmer_configs(
153    dir_path: str | pathlib.Path,
154    *,
155    xml_root: pathlib.Path | None = None,
156) -> Iterator[ProgrammerConfig]:
157    """Walk a ``programmers/`` directory yielding one
158    :class:`ProgrammerConfig` per top-level ``*.xml``. Sub-directory
159    ``parts/`` is skipped — those are the include targets, not
160    programmer definitions.
161    """
162    root = pathlib.Path(dir_path)
163    if xml_root is None:
164        xml_root = root.parent   # <jmri>/xml
165    for xml in sorted(root.glob("*.xml")):
166        try:
167            yield load_programmer_config(xml, xml_root=xml_root)
168        except etree.XMLSyntaxError as e:
169            _log.debug("iter_programmer_configs: XML parse failure %s: %s", xml, e)
170            continue
171        except ValueError as e:
172            _log.debug("iter_programmer_configs: skipping %s (%s)", xml, e)
173            continue
174        except OSError as e:
175            _log.warning("iter_programmer_configs: cannot read %s: %s", xml, e)
176            continue

Walk a programmers/ directory yielding one ProgrammerConfig per top-level *.xml. Sub-directory parts/ is skipped — those are the include targets, not programmer definitions.

def load_programmer_config( path: str | pathlib._local.Path, *, xml_root: pathlib._local.Path | None = None) -> ProgrammerConfig:
134def load_programmer_config(
135    path: str | pathlib.Path,
136    *,
137    xml_root: pathlib.Path | None = None,
138) -> ProgrammerConfig:
139    """Load a ``<programmer-config>`` file from disk.
140
141    *xml_root* defaults to the file's grandparent (JMRI's standard
142    layout: ``<xml_root>/programmers/foo.xml``).
143    """
144    p = pathlib.Path(path)
145    if xml_root is None:
146        xml_root = p.parent.parent
147    return load_programmer_config_bytes(
148        p.read_bytes(), xml_root=xml_root, source_file=p,
149    )

Load a <programmer-config> file from disk.

xml_root defaults to the file's grandparent (JMRI's standard layout: <xml_root>/programmers/foo.xml).

def load_programmer_config_bytes( data: bytes, *, xml_root: pathlib._local.Path, source_file: pathlib._local.Path | None = None) -> ProgrammerConfig:
 73def load_programmer_config_bytes(
 74    data: bytes,
 75    *,
 76    xml_root: pathlib.Path,
 77    source_file: pathlib.Path | None = None,
 78) -> ProgrammerConfig:
 79    """Parse a ``<programmer-config>`` byte string, resolving xi:includes.
 80
 81    *xml_root* is the directory ``http://jmri.org/xml/`` maps to
 82    (typically ``<JMRI install>/xml``). Includes whose href lies outside
 83    that prefix, or whose target file is missing, are recorded in
 84    :attr:`ProgrammerConfig.unresolved_includes` and skipped.
 85    """
 86    parser = etree.XMLParser(remove_blank_text=False, resolve_entities=False)
 87    root = etree.fromstring(data, parser=parser)
 88    if root.tag != "programmer-config":
 89        raise ValueError(f"expected <programmer-config>, got <{root.tag}>")
 90
 91    programmer = root.find("programmer")
 92    panes: list[DecoderPane] = []
 93    unresolved: list[str] = []
 94    decoder_file_panes = True
 95    show_empty_panes = "default"
 96
 97    if programmer is not None:
 98        decoder_file_panes = (
 99            programmer.get("decoderFilePanes", "yes").lower() == "yes"
100        )
101        show_empty_panes = programmer.get("showEmptyPanes", "default")
102        for inc in programmer.iter(f"{XINCLUDE_NS}include"):
103            href = inc.get("href", "")
104            if not href.startswith(_JMRI_URL_PREFIX):
105                unresolved.append(href)
106                continue
107            rel = href[len(_JMRI_URL_PREFIX):]
108            fragment = xml_root / rel
109            if not fragment.is_file():
110                unresolved.append(href)
111                _log.debug("programmer include not found: %s", fragment)
112                continue
113            try:
114                pane = load_pane_bytes(fragment.read_bytes())
115            except (etree.XMLSyntaxError, ValueError, OSError) as e:
116                unresolved.append(href)
117                _log.warning(
118                    "programmer include %s failed to parse: %s", fragment, e,
119                )
120                continue
121            panes.append(pane)
122
123    name = source_file.stem if source_file is not None else ""
124    return ProgrammerConfig(
125        name=name,
126        panes=panes,
127        unresolved_includes=unresolved,
128        source_file=source_file,
129        decoder_file_panes=decoder_file_panes,
130        show_empty_panes=show_empty_panes,
131    )

Parse a <programmer-config> byte string, resolving xi:includes.

xml_root is the directory http://jmri.org/xml/ maps to (typically <JMRI install>/xml). Includes whose href lies outside that prefix, or whose target file is missing, are recorded in ProgrammerConfig.unresolved_includes and skipped.

64class Car(_AttrModel):
65    id: str
66    road_name: str = Field(alias="roadName", default="")
67    road_number: str = Field(alias="roadNumber", default="")
68    type: str = ""
69    length: str = ""
70    location_id: str = Field(alias="locationId", default="")
71    load: str = ""

Base for the typed accessors. Stores the common attributes and overflows everything else into extra. __source holds the originating element if loaded from XML.

id: str = PydanticUndefined
road_name: str = ''
road_number: str = ''
type: str = ''
length: str = ''
location_id: str = ''
load: str = ''
53class Engine(_AttrModel):
54    id: str
55    road_name: str = Field(alias="roadName", default="")
56    road_number: str = Field(alias="roadNumber", default="")
57    type: str = ""
58    length: str = ""
59    location_id: str = Field(alias="locationId", default="")
60    model: str = ""
61    consist: str = ""

Base for the typed accessors. Stores the common attributes and overflows everything else into extra. __source holds the originating element if loaded from XML.

id: str = PydanticUndefined
road_name: str = ''
road_number: str = ''
type: str = ''
length: str = ''
location_id: str = ''
model: str = ''
consist: str = ''
74class Location(_AttrModel):
75    id: str
76    name: str = ""
77    location_type: str = Field(alias="locationOps", default="")

Base for the typed accessors. Stores the common attributes and overflows everything else into extra. __source holds the originating element if loaded from XML.

id: str = PydanticUndefined
name: str = ''
location_type: str = ''
@dataclass
class OperationsFile:
104@dataclass
105class OperationsFile:
106    """An operations-config XML file as a structured object.
107
108    Keeps the full lxml tree (``root``) so unknown attributes/elements
109    survive round-trip. Typed accessors (:meth:`engines`, :meth:`cars`, …)
110    project the major top-level elements into Pydantic models for callers
111    who want type-checked access.
112    """
113
114    root: etree._Element
115    doctype: str | None = None
116
117    # ----- read accessors ---------------------------------------------------
118
119    @property
120    def kind(self) -> str:
121        """Best-guess label for this file based on the top-level children."""
122        for guess in (
123            "operations", "engines", "cars", "routes",
124            "locations", "trains", "schedules",
125        ):
126            if self.root.find(guess) is not None:
127                return guess
128        return "unknown"
129
130    def engines(self) -> list[Engine]:
131        elem = self.root.find("engines")
132        if elem is None:
133            return []
134        return [_elem_to_model(e, Engine) for e in elem.findall("engine")]
135
136    def cars(self) -> list[Car]:
137        elem = self.root.find("cars")
138        if elem is None:
139            return []
140        return [_elem_to_model(c, Car) for c in elem.findall("car")]
141
142    def locations(self) -> list[Location]:
143        elem = self.root.find("locations")
144        if elem is None:
145            return []
146        return [_elem_to_model(loc, Location) for loc in elem.findall("location")]
147
148    def routes(self) -> list[Route]:
149        elem = self.root.find("routes")
150        if elem is None:
151            return []
152        out: list[Route] = []
153        for r in elem.findall("route"):
154            route = _elem_to_model(r, Route)
155            route.locations = [
156                _elem_to_model(loc_el, RouteLocation) for loc_el in r.findall("location")
157            ]
158            out.append(route)
159        return out
160
161    def trains(self) -> list[Train]:
162        elem = self.root.find("trains")
163        if elem is None:
164            return []
165        return [_elem_to_model(t, Train) for t in elem.findall("train")]
166
167    def setup(self) -> dict[str, dict[str, str]]:
168        """Return the ``<operations>`` setup block as nested attribute dicts.
169
170        Each child element of ``<operations>`` becomes one entry keyed by
171        tag, value is the attribute dict. Order of children is preserved
172        only in the underlying lxml tree, not in the returned dict.
173        """
174        op = self.root.find("operations")
175        if op is None:
176            return {}
177        return {
178            str(child.tag): {str(k): str(v) for k, v in child.attrib.items()}
179            for child in op
180        }
181
182    # ----- iteration --------------------------------------------------------
183
184    def iter_top_level(self) -> Iterator[etree._Element]:
185        return iter(self.root)

An operations-config XML file as a structured object.

Keeps the full lxml tree (root) so unknown attributes/elements survive round-trip. Typed accessors (engines(), cars(), …) project the major top-level elements into Pydantic models for callers who want type-checked access.

OperationsFile(root: lxml.etree._Element, doctype: str | None = None)
root: lxml.etree._Element
doctype: str | None = None
kind: str
119    @property
120    def kind(self) -> str:
121        """Best-guess label for this file based on the top-level children."""
122        for guess in (
123            "operations", "engines", "cars", "routes",
124            "locations", "trains", "schedules",
125        ):
126            if self.root.find(guess) is not None:
127                return guess
128        return "unknown"

Best-guess label for this file based on the top-level children.

def engines(self) -> list[Engine]:
130    def engines(self) -> list[Engine]:
131        elem = self.root.find("engines")
132        if elem is None:
133            return []
134        return [_elem_to_model(e, Engine) for e in elem.findall("engine")]
def cars(self) -> list[Car]:
136    def cars(self) -> list[Car]:
137        elem = self.root.find("cars")
138        if elem is None:
139            return []
140        return [_elem_to_model(c, Car) for c in elem.findall("car")]
def locations(self) -> list[Location]:
142    def locations(self) -> list[Location]:
143        elem = self.root.find("locations")
144        if elem is None:
145            return []
146        return [_elem_to_model(loc, Location) for loc in elem.findall("location")]
def routes(self) -> list[Route]:
148    def routes(self) -> list[Route]:
149        elem = self.root.find("routes")
150        if elem is None:
151            return []
152        out: list[Route] = []
153        for r in elem.findall("route"):
154            route = _elem_to_model(r, Route)
155            route.locations = [
156                _elem_to_model(loc_el, RouteLocation) for loc_el in r.findall("location")
157            ]
158            out.append(route)
159        return out
def trains(self) -> list[Train]:
161    def trains(self) -> list[Train]:
162        elem = self.root.find("trains")
163        if elem is None:
164            return []
165        return [_elem_to_model(t, Train) for t in elem.findall("train")]
def setup(self) -> dict[str, dict[str, str]]:
167    def setup(self) -> dict[str, dict[str, str]]:
168        """Return the ``<operations>`` setup block as nested attribute dicts.
169
170        Each child element of ``<operations>`` becomes one entry keyed by
171        tag, value is the attribute dict. Order of children is preserved
172        only in the underlying lxml tree, not in the returned dict.
173        """
174        op = self.root.find("operations")
175        if op is None:
176            return {}
177        return {
178            str(child.tag): {str(k): str(v) for k, v in child.attrib.items()}
179            for child in op
180        }

Return the <operations> setup block as nested attribute dicts.

Each child element of <operations> becomes one entry keyed by tag, value is the attribute dict. Order of children is preserved only in the underlying lxml tree, not in the returned dict.

def iter_top_level(self) -> Iterator[lxml.etree._Element]:
184    def iter_top_level(self) -> Iterator[etree._Element]:
185        return iter(self.root)
87class Route(_AttrModel):
88    id: str
89    name: str = ""
90    comment: str = ""
91    locations: list[RouteLocation] = Field(default_factory=list)

Base for the typed accessors. Stores the common attributes and overflows everything else into extra. __source holds the originating element if loaded from XML.

id: str = PydanticUndefined
name: str = ''
comment: str = ''
locations: list[RouteLocation] = PydanticUndefined
class RouteLocation(jmri_core.persistence.operations_xml._AttrModel):
80class RouteLocation(_AttrModel):
81    id: str
82    name: str = ""
83    location_id: str = Field(alias="locationId", default="")
84    sequence_id: str = Field(alias="sequenceId", default="")

Base for the typed accessors. Stores the common attributes and overflows everything else into extra. __source holds the originating element if loaded from XML.

id: str = PydanticUndefined
name: str = ''
location_id: str = ''
sequence_id: str = ''
94class Train(_AttrModel):
95    id: str
96    name: str = ""
97    description: str = ""
98    route_id: str = Field(alias="route", default="")

Base for the typed accessors. Stores the common attributes and overflows everything else into extra. __source holds the originating element if loaded from XML.

id: str = PydanticUndefined
name: str = ''
description: str = ''
route_id: str = ''
def load_operations( path: str | pathlib._local.Path) -> OperationsFile:
204def load_operations(path: str | pathlib.Path) -> OperationsFile:
205    return load_operations_bytes(pathlib.Path(path).read_bytes())
def load_operations_bytes(data: bytes) -> OperationsFile:
191def load_operations_bytes(data: bytes) -> OperationsFile:
192    parser = etree.XMLParser(
193        remove_blank_text=False,
194        resolve_entities=False,
195        load_dtd=False,
196        no_network=True,
197    )
198    tree = etree.fromstring(data, parser=parser)
199    if tree.tag != "operations-config":
200        raise ValueError(f"expected <operations-config>, got <{tree.tag}>")
201    return OperationsFile(root=tree, doctype=_extract_doctype(tree))
def save_operations( path: str | pathlib._local.Path, file: OperationsFile) -> None:
227def save_operations(path: str | pathlib.Path, file: OperationsFile) -> None:
228    pathlib.Path(path).write_bytes(save_operations_bytes(file))
def save_operations_bytes(file: OperationsFile) -> bytes:
208def save_operations_bytes(file: OperationsFile) -> bytes:
209    if file.doctype:
210        return etree.tostring(
211            file.root,
212            xml_declaration=True,
213            encoding="utf-8",
214            pretty_print=False,
215            standalone=False,
216            doctype=file.doctype,
217        )
218    return etree.tostring(
219        file.root,
220        xml_declaration=True,
221        encoding="utf-8",
222        pretty_print=False,
223        standalone=False,
224    )
@dataclass
class PanelFile:
26@dataclass
27class PanelFile:
28    """A panel / layout XML file with preserved structure.
29
30    Attributes:
31        root: top-level lxml ``<layout-config>`` element (or whatever the
32            file's root happens to be — we don't reject unknown roots).
33        doctype: original DOCTYPE declaration string, preserved across save.
34    """
35
36    root: etree._Element
37    doctype: str | None = None
38
39    @property
40    def root_tag(self) -> str:
41        return str(self.root.tag)
42
43    @property
44    def jmri_version(self) -> str:
45        """JMRI version that wrote this file, from the root attributes."""
46        return self.root.get("jmriversion") or self.root.get("jmriVersion") or ""
47
48    def panels(self) -> list[etree._Element]:
49        """List of top-level ``<panel>`` elements (PanelPro-style files)."""
50        return self.root.findall("panel")
51
52    def layout_editors(self) -> list[etree._Element]:
53        """List of ``<LayoutEditor>`` elements (LayoutEditor-style files)."""
54        return self.root.findall("LayoutEditor")
55
56    def named_beans(self, manager_class: str) -> list[etree._Element]:
57        """All ``<*Manager>`` child elements matching *manager_class*.
58
59        Panel files persist managed-bean state inside the panel-config so
60        ``turnoutmanager``, ``sensormanager``, etc. show up at the root.
61        """
62        return self.root.findall(manager_class)

A panel / layout XML file with preserved structure.

Attributes: root: top-level lxml <layout-config> element (or whatever the file's root happens to be — we don't reject unknown roots). doctype: original DOCTYPE declaration string, preserved across save.

PanelFile(root: lxml.etree._Element, doctype: str | None = None)
root: lxml.etree._Element
doctype: str | None = None
root_tag: str
39    @property
40    def root_tag(self) -> str:
41        return str(self.root.tag)
jmri_version: str
43    @property
44    def jmri_version(self) -> str:
45        """JMRI version that wrote this file, from the root attributes."""
46        return self.root.get("jmriversion") or self.root.get("jmriVersion") or ""

JMRI version that wrote this file, from the root attributes.

def panels(self) -> list[lxml.etree._Element]:
48    def panels(self) -> list[etree._Element]:
49        """List of top-level ``<panel>`` elements (PanelPro-style files)."""
50        return self.root.findall("panel")

List of top-level <panel> elements (PanelPro-style files).

def layout_editors(self) -> list[lxml.etree._Element]:
52    def layout_editors(self) -> list[etree._Element]:
53        """List of ``<LayoutEditor>`` elements (LayoutEditor-style files)."""
54        return self.root.findall("LayoutEditor")

List of <LayoutEditor> elements (LayoutEditor-style files).

def named_beans(self, manager_class: str) -> list[lxml.etree._Element]:
56    def named_beans(self, manager_class: str) -> list[etree._Element]:
57        """All ``<*Manager>`` child elements matching *manager_class*.
58
59        Panel files persist managed-bean state inside the panel-config so
60        ``turnoutmanager``, ``sensormanager``, etc. show up at the root.
61        """
62        return self.root.findall(manager_class)

All <*Manager> child elements matching manager_class.

Panel files persist managed-bean state inside the panel-config so turnoutmanager, sensormanager, etc. show up at the root.

def load_panel( path: str | pathlib._local.Path) -> PanelFile:
79def load_panel(path: str | pathlib.Path) -> PanelFile:
80    return load_panel_bytes(pathlib.Path(path).read_bytes())
def load_panel_bytes(data: bytes) -> PanelFile:
65def load_panel_bytes(data: bytes) -> PanelFile:
66    parser = etree.XMLParser(
67        remove_blank_text=False,
68        resolve_entities=False,
69        load_dtd=False,
70        no_network=True,
71    )
72    root = etree.fromstring(data, parser=parser)
73    tree = root.getroottree()
74    # getattr shields us from lxml stub gaps around DocInfo.doctype.
75    doctype: str | None = getattr(tree.docinfo, "doctype", None) or None
76    return PanelFile(root=root, doctype=doctype)
def save_panel( path: str | pathlib._local.Path, file: PanelFile) -> None:
102def save_panel(path: str | pathlib.Path, file: PanelFile) -> None:
103    pathlib.Path(path).write_bytes(save_panel_bytes(file))
def save_panel_bytes(file: PanelFile) -> bytes:
83def save_panel_bytes(file: PanelFile) -> bytes:
84    if file.doctype:
85        return etree.tostring(
86            file.root,
87            xml_declaration=True,
88            encoding="utf-8",
89            pretty_print=False,
90            standalone=False,
91            doctype=file.doctype,
92        )
93    return etree.tostring(
94        file.root,
95        xml_declaration=True,
96        encoding="utf-8",
97        pretty_print=False,
98        standalone=False,
99    )