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:
jmri_core.persistence.profile—Profileand the standard~/JMRI/directory layout.jmri_core.persistence.roster_index_xml—roster.xmlindex file (perroster.xsd).jmri_core.persistence.decoder_xml— read-only decoder definition loader for the 881xml/decoders/*.xmlfiles.jmri_core.persistence.operations_xml— semantic round-trip for all six Operations module file families.jmri_core.persistence.panel_xml— envelope-level round-trip for PanelPro / LayoutEditor files (full element modeling is Stage 11).
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]
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.
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.
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.
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.
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.
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.
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.
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.
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.
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()):
<settings-root>/python/— cross-profile shared scripts.<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.
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.
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.
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).
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.
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.
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.
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.
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:
<settings>/.active_profilemarker file (our own bookkeeping).- First profile in
profiles.xml(JMRI's implicit default).
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.
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).
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.
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.
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.
253def load_cv_values_from_locomotive_config_bytes( 254 data: bytes, *, loco_id: str | None = None, 255) -> LocoCvState | None: 256 """Bytes-in variant of :func:`load_cv_values_from_locomotive_config`.""" 257 parser = etree.XMLParser( 258 remove_blank_text=False, 259 resolve_entities=False, 260 load_dtd=False, 261 no_network=True, 262 ) 263 try: 264 root = etree.fromstring(data, parser=parser) 265 except etree.XMLSyntaxError: 266 return None 267 if root.tag != "locomotive-config": 268 return None 269 for loco in root.iter("locomotive"): 270 this_id = loco.get("id", "") 271 if loco_id is not None and this_id != loco_id: 272 continue 273 values = loco.find("values") 274 if values is None: 275 return None 276 cv_values: dict[str, int] = {} 277 # Accept both element shapes: 278 # * ``<CVvalue name=... value=.../>`` — what real JMRI Java 279 # writes inside ``<values>``. This is the JMRI-canonical form. 280 # * ``<CV name=... value=.../>`` — what earlier versions of our 281 # own code wrote. Kept for backwards compat on files we've 282 # already touched. 283 for tag in ("CVvalue", "CV"): 284 for cv in values.iter(tag): 285 name = cv.get("name") 286 val = cv.get("value") 287 if not name or val is None: 288 continue 289 try: 290 cv_values[name] = int(val) 291 except ValueError: 292 continue 293 return LocoCvState(loco_id=this_id or (loco_id or ""), cv_values=cv_values) 294 return None
Bytes-in variant of load_cv_values_from_locomotive_config().
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.
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.
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.
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)
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.
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.
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.
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)
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.
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)
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.
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.
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.
331def load_config_bytes(data: bytes) -> ConfigFile: 332 """Parse a ``<layout-config>`` PanelPro/Configuration file from bytes. 333 334 ``remove_blank_text=True`` strips the whitespace-only text nodes 335 JMRI writes between siblings so lxml's ``pretty_print`` on save 336 produces cleanly indented output. Without this, freshly-added 337 ``<turnouts>`` / ``<sensors>`` sections come out on one gigantic 338 line — unreadable when a user tries to hand-edit the file. Safe 339 for us because the file is element-only (no mixed content that 340 would care about internal whitespace).""" 341 parser = etree.XMLParser( 342 remove_blank_text=True, 343 resolve_entities=False, 344 load_dtd=False, 345 no_network=True, 346 ) 347 root = etree.fromstring(data, parser=parser) 348 if root.tag != "layout-config": 349 raise ValueError( 350 f"expected <layout-config> root, got <{root.tag}>" 351 ) 352 tree = root.getroottree() 353 doctype: str | None = getattr(tree.docinfo, "doctype", None) or None 354 355 config = ConfigFile( 356 root_attribs={k: v for k, v in root.attrib.items()}, 357 doctype=doctype, 358 ) 359 for child in root: 360 if not isinstance(child.tag, str): 361 config.preserved.append(child) 362 continue 363 if child.tag == "turnouts": 364 config.turnout_sections.append(_parse_turnout_section(child)) 365 elif child.tag == "sensors": 366 config.sensor_sections.append(_parse_sensor_section(child)) 367 elif child.tag == "lights": 368 config.light_sections.append(_parse_light_section(child)) 369 else: 370 config.preserved.append(child) 371 return config
Parse a <layout-config> PanelPro/Configuration file from bytes.
remove_blank_text=True strips the whitespace-only text nodes
JMRI writes between siblings so lxml's pretty_print on save
produces cleanly indented output. Without this, freshly-added
<turnouts> / <sensors> sections come out on one gigantic
line — unreadable when a user tries to hand-edit the file. Safe
for us because the file is element-only (no mixed content that
would care about internal whitespace).
444def save_config_bytes(config: ConfigFile) -> bytes: 445 """Serialize a :class:`ConfigFile` back to XML bytes (JMRI 5.x shape).""" 446 root = etree.Element("layout-config", attrib=config.root_attribs) 447 for section in config.turnout_sections: 448 _write_turnout_section(root, section) 449 for section in config.sensor_sections: 450 _write_sensor_section(root, section) 451 for section in config.light_sections: 452 _write_light_section(root, section) 453 for elem in config.preserved: 454 root.append(elem) 455 common_kwargs: dict[str, object] = { 456 "xml_declaration": True, 457 "encoding": "utf-8", 458 "pretty_print": True, 459 "standalone": False, 460 } 461 if config.doctype: 462 common_kwargs["doctype"] = config.doctype 463 return etree.tostring(root, **common_kwargs)
Serialize a ConfigFile back to XML bytes (JMRI 5.x shape).
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.
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.
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.
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.
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").
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.
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)
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 )
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.
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
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 theV-region (from bit 0)width— number ofVbitsmask_value— the raw byte mask for theV-region
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.
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.
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.
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.
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.
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.
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).
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.
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.
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:
kind— which value-type child element the variable declaredbit_mask— parsedCvBitMask(never None; a variable without a mask still has a full-byte mask)enum_choices— ordered choices for ENUM variablescomposite_choices— ordered choices for COMPOSITE variablessplit— parsedSplitCvfor SPLIT/SPLIT_HEX/etcconstant_value— for the CONSTANT kindread_only/write_only— attribute flagstooltip— hover text
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.
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 (with0xC0bits 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).
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 )
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).
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.
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.
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.
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).
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.
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.
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.
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 asvariable_extract_from_store()consumes).
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.
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, orNonewhen the op is bank/index setup (CV31/CV32) that shouldn't leave a per-write trace.
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.
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_counton the parsedSplitCv.
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.
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).
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.
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".
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).
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.
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.
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 successfulxi:includeresolution.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.
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.
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).
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.
Inherited Members
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.
Inherited Members
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.
Inherited Members
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.
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.
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
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.
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.
Inherited Members
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.
Inherited Members
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.
Inherited Members
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))
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 )
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.
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.
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).
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).
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.
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)
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 )