jmri_core.persistence.decoder_xml
Decoder definition XML loader — read-only.
Each file under xml/decoders/*.xml describes one decoder family (one or
more models) plus the CVs each model exposes. JMRI ships 881 such files.
Stage 2 ships a read-only parser: enough to enumerate manufacturers, families, models, and CV variables, so Stage 3+ can serve roster data and Stage 8 (DecoderPro) can build a UI on top. Editing/writing decoder XML is not part of this stage — JMRI itself is the source of truth for these files.
The model intentionally captures only the major attributes. The full schema
decoder-4-15-2.xsd declares dozens of element types; rather than model
every one, we expose:
- family + model identity (vendor, family name, model name, connector type);
- per-model attributes JMRI cares about for roster matching;
- a flat list of
DecoderVariableentries (CV definitions); - the raw lxml
ElementviaDecoderFile.treefor callers who need to reach into uncaptured corners (programmer pane layouts, etc.).
1"""Decoder definition XML loader — read-only. 2 3Each file under ``xml/decoders/*.xml`` describes one decoder family (one or 4more models) plus the CVs each model exposes. JMRI ships 881 such files. 5 6Stage 2 ships a **read-only** parser: enough to enumerate manufacturers, 7families, models, and CV variables, so Stage 3+ can serve roster data and 8Stage 8 (DecoderPro) can build a UI on top. Editing/writing decoder XML is 9not part of this stage — JMRI itself is the source of truth for these files. 10 11The model intentionally captures only the major attributes. The full schema 12``decoder-4-15-2.xsd`` declares dozens of element types; rather than model 13every one, we expose: 14 15* family + model identity (vendor, family name, model name, connector type); 16* per-model attributes JMRI cares about for roster matching; 17* a flat list of :class:`DecoderVariable` entries (CV definitions); 18* the raw lxml ``Element`` via :attr:`DecoderFile.tree` for callers who need 19 to reach into uncaptured corners (programmer pane layouts, etc.). 20""" 21from __future__ import annotations 22 23import logging 24import pathlib 25from collections.abc import Iterable, Iterator 26from dataclasses import dataclass 27from enum import StrEnum 28 29from lxml import etree 30from pydantic import BaseModel, ConfigDict, Field 31 32_log = logging.getLogger(__name__) 33 34 35class DecoderOutput(BaseModel): 36 """Function-output mapping declared by a model.""" 37 name: str 38 label: str = "" 39 40 41# ----- variable value-type modelling ------------------------------------ 42 43 44class VariableKind(StrEnum): 45 """The value-type child element declared inside a ``<variable>``. 46 47 ``UNKNOWN`` is the parser's fallback when a variable declares something 48 we don't model yet; downstream code treats it as an opaque byte. 49 """ 50 DECIMAL = "decimal" # <decVal> 51 HEXADECIMAL = "hexadecimal" # <hexVal> 52 ENUM = "enum" # <enumVal>/<enumChoice> 53 SPEED_TABLE = "speed_table" # <speedTable> 54 SHORT_ADDRESS = "short_address" # <shortAddressVal> (CV1) 55 LONG_ADDRESS = "long_address" # <longAddressVal> (CV17+CV18) 56 SPLIT = "split" # <splitVal> 57 SPLIT_HEX = "split_hex" # <splitHexVal> 58 SPLIT_DATETIME = "split_datetime" # <splitDateTimeVal> 59 COMPOSITE = "composite" # <compositeVal> 60 CONSTANT = "constant" # <constant> 61 UNKNOWN = "unknown" 62 63 64class EnumChoice(BaseModel): 65 """One ``<enumChoice value="V">text</enumChoice>`` option. 66 67 JMRI allows the ``value`` attribute to be omitted, in which case the 68 positional index (0, 1, 2, …) is the wire value. The parser fills 69 that in so downstream code can treat all choices uniformly. 70 """ 71 value: int 72 choice: str = "" 73 choice_by_lang: dict[str, str] = Field(default_factory=dict) 74 75 76class CompositeSetting(BaseModel): 77 """One ``<compositeSetting label="X" value="Y"/>`` inside a compositeChoice. 78 79 ``label`` references another variable via its ``item`` name; picking 80 this composite choice writes ``value`` into that referenced variable. 81 """ 82 label: str 83 value: int 84 85 86class CompositeChoice(BaseModel): 87 """One ``<compositeChoice choice="X"><compositeSetting/>…</compositeChoice>``. 88 89 Composite variables are meta-variables — a user pick fans out into 90 multiple sub-variable writes. The write path needs the session-level 91 queue from step 4 to batch those atomically; step 2 just models the 92 structure. 93 """ 94 choice: str 95 choice_by_lang: dict[str, str] = Field(default_factory=dict) 96 settings: list[CompositeSetting] = Field(default_factory=list) 97 98 99class CvBitMask(BaseModel): 100 """Parsed ``<variable mask="XXVXXXXX">`` notation. 101 102 JMRI masks are written MSB → LSB with ``V`` marking bits this 103 variable owns and ``X`` marking don't-cares. ``"XXVXXXXX"`` means 104 bit 5 (``0b00100000``). 105 106 * ``shift`` — position of the LSB of the ``V``-region (from bit 0) 107 * ``width`` — number of ``V`` bits 108 * ``mask_value`` — the raw byte mask for the ``V``-region 109 """ 110 model_config = ConfigDict(frozen=True) 111 112 raw: str = "" 113 shift: int = 0 114 width: int = 8 115 116 @classmethod 117 def parse(cls, s: str | None) -> CvBitMask: 118 """Turn a JMRI mask string into a structured mask. 119 120 Empty / all-``X`` / missing masks give a full-byte mask (shift=0, 121 width=8) — that's what JMRI treats as "no mask" and it means the 122 variable owns the whole byte. 123 """ 124 if not s: 125 return cls(raw="", shift=0, width=8) 126 s = s.strip() 127 if not s or "V" not in s: 128 return cls(raw=s, shift=0, width=len(s) or 8) 129 total = len(s) 130 first_v = s.find("V") 131 last_v = s.rfind("V") 132 shift = total - 1 - last_v 133 width = last_v - first_v + 1 134 return cls(raw=s, shift=shift, width=width) 135 136 @property 137 def mask_value(self) -> int: 138 """The byte mask that isolates this variable's V-region.""" 139 return ((1 << self.width) - 1) << self.shift 140 141 def extract(self, byte: int) -> int: 142 """Read this variable's value out of a raw byte.""" 143 return (byte & self.mask_value) >> self.shift 144 145 def apply(self, byte: int, value: int) -> int: 146 """Stuff *value* into the V-region of *byte*, preserving other bits. 147 148 The read-modify-write step the caller performs before writing 149 back: fetch the current byte, call ``apply(byte, new_value)``, 150 write the resulting byte. Values wider than ``width`` bits are 151 truncated to the region size. 152 """ 153 room = (1 << self.width) - 1 154 return (byte & ~self.mask_value) | ((value & room) << self.shift) 155 156 157class SplitCv(BaseModel): 158 """Split-CV addressing parsed from ``<variable CV="…">`` attribute strings. 159 160 Three JMRI-supported shapes: 161 162 * ``"1"`` — one CV, single byte 163 * ``"17.18"`` — long-address pattern: CV17 is the high byte 164 (with ``0xC0`` bits set), CV18 is the low byte 165 * ``"0.255.261"`` — bank/index/base indexed access (ESU-style) 166 * ``"0.255.261:4"`` — same as above, 4 consecutive bytes 167 168 ``cv_list`` gives the ordered CVs the variable touches, in 169 high-byte-first order (the read/write path iterates them). 170 """ 171 model_config = ConfigDict(frozen=True) 172 173 cv_attr: str 174 primary_cv: int = 0 175 byte_count: int = 1 176 bank: int | None = None 177 index: int | None = None 178 179 @classmethod 180 def parse(cls, s: str) -> SplitCv: 181 if not s: 182 return cls(cv_attr="", primary_cv=0, byte_count=1) 183 s = s.strip() 184 if ":" in s: 185 head, count = s.split(":", 1) 186 try: 187 byte_count = max(1, int(count)) 188 except ValueError: 189 byte_count = 1 190 else: 191 head = s 192 byte_count = 1 193 parts = head.split(".") 194 try: 195 nums = [int(p) for p in parts] 196 except ValueError: 197 return cls(cv_attr=s, primary_cv=0, byte_count=byte_count) 198 if len(nums) == 1: 199 return cls(cv_attr=s, primary_cv=nums[0], byte_count=byte_count) 200 if len(nums) == 2: 201 # Two adjacent CVs — the long-address pattern (CV17.CV18) 202 # or any two-byte pair. 203 return cls( 204 cv_attr=s, primary_cv=nums[0], 205 byte_count=max(byte_count, 2), 206 ) 207 # 3+ parts: bank.index.base 208 return cls( 209 cv_attr=s, 210 bank=nums[0], index=nums[1], 211 primary_cv=nums[2], 212 byte_count=byte_count, 213 ) 214 215 @property 216 def cv_list(self) -> list[int]: 217 """The CVs this variable touches, in high-byte-first order.""" 218 return [self.primary_cv + i for i in range(self.byte_count)] 219 220 221class DecoderVariable(BaseModel): 222 """One CV (or CV-group) variable as declared in ``<variables>``. 223 224 Backwards-compatible fields (``cv`` through ``ops_only``) stay 225 exactly as parsed in earlier stages. Step 2 adds: 226 227 * ``kind`` — which value-type child element the variable declared 228 * ``bit_mask`` — parsed :class:`CvBitMask` (never None; a variable 229 without a mask still has a full-byte mask) 230 * ``enum_choices`` — ordered choices for ENUM variables 231 * ``composite_choices`` — ordered choices for COMPOSITE variables 232 * ``split`` — parsed :class:`SplitCv` for SPLIT/SPLIT_HEX/etc 233 * ``constant_value`` — for the CONSTANT kind 234 * ``read_only`` / ``write_only`` — attribute flags 235 * ``tooltip`` — hover text 236 """ 237 model_config = ConfigDict(populate_by_name=True) 238 239 cv: str = Field(alias="CV", default="") 240 item: str = "" 241 # ``<variable default="N">`` — the *human* default value the 242 # decoder ships with (for a masked-bit variable, the masked 243 # value; for a decimal variable, the whole byte). Parsed to an 244 # ``int`` at load time via :func:`_parse_default_int` so downstream 245 # consumers (default-overlay in the pane render, diff-filter in 246 # LocoCvStore, CV7 identity match) don't have to re-parse on 247 # every access. ``None`` when the attribute is absent or the value 248 # isn't a plain integer. 249 default: int | None = None 250 mask: str | None = None 251 min: str | None = None 252 max: str | None = None 253 label: str = "" 254 ops_only: bool = False 255 256 # step-2 additions 257 kind: VariableKind = VariableKind.UNKNOWN 258 bit_mask: CvBitMask = Field(default_factory=CvBitMask) 259 enum_choices: list[EnumChoice] = Field(default_factory=list) 260 composite_choices: list[CompositeChoice] = Field(default_factory=list) 261 split: SplitCv | None = None 262 constant_value: int | None = None 263 read_only: bool = False 264 write_only: bool = False 265 tooltip: str = "" 266 # SPEED_TABLE only: number of speed-step entries the table covers. 267 # NMRA standard is 28 (CV67..CV94); some decoders declare a 14-step 268 # or 128-step variant via ``<speedTable entries="N">``. 269 speed_table_entries: int = 28 270 # ``<variable include="pid1,pid2,…">`` / ``exclude="…"``: show this 271 # variable only for models whose ``productID`` is in the include 272 # list (or NOT in the exclude list). Empty means "no filter". 273 # SoundTraxx Tsunami2 uses this heavily so a single decoder file 274 # can serve dozens of model revisions: e.g. HEP Mode / Turbine 275 # Start-Stop / Stop Request Bell share a CV and take turns being 276 # applicable per productID. 277 include_product_ids: list[str] = Field(default_factory=list) 278 exclude_product_ids: list[str] = Field(default_factory=list) 279 # ``<qualifier><variableref>X</variableref><relation>Y</relation> 280 # <value>N</value></qualifier>`` — runtime visibility gate. All 281 # qualifiers must pass (AND) for the variable to be shown. Common 282 # relations: ``eq``, ``ne``, ``gt``, ``ge``, ``lt``, ``le``, 283 # ``exists``. SoundTraxx uses this to split a shared CV across 284 # multiple named variables depending on another CV's current 285 # value (HEP Mode / HEP Mode2 / Turbine Start-Stop pattern). 286 qualifiers: list[dict[str, str]] = Field(default_factory=list) 287 288 289class DecoderModel(BaseModel): 290 """One model within a family.""" 291 model: str 292 connector: str = "" 293 show_empty_panes: str | None = None 294 max_input_volts: str | None = None 295 max_motor_current: str | None = None 296 outputs: list[DecoderOutput] = Field(default_factory=list) 297 protocols: list[str] = Field(default_factory=list) 298 size_length: str | None = None 299 size_width: str | None = None 300 size_height: str | None = None 301 size_units: str | None = None 302 # ``<model productID="…"/>`` — SoundTraxx, Digitrax, etc. use this 303 # to filter variables per specific decoder revision. When a 304 # ``<variable>`` carries ``include="pid1,pid2,…"`` or ``exclude="…"``, 305 # the UI shows/hides it based on whether this model's productID is 306 # in the list. 307 product_id: str = "" 308 309 310class DecoderFamily(BaseModel): 311 """One family containing one or more :class:`DecoderModel` entries.""" 312 name: str 313 mfg: str = "" 314 models: list[DecoderModel] = Field(default_factory=list) 315 # Family-level ``<output>`` declarations. Some decoders (SoundTraxx 316 # Tsunami2, Digitrax, …) put ``<output name="N" label="…"/>`` after 317 # all the ``<model>`` elements so the labels apply to every model 318 # in the family. Callers that render function-map column headers 319 # should fall back here when a model has no outputs of its own. 320 outputs: list[DecoderOutput] = Field(default_factory=list) 321 322 323class DecoderPane(BaseModel): 324 """One JMRI programmer pane declared inside a decoder file. 325 326 Panes are the tabs in DecoderPro's programmer UI (Basic, Motor, 327 Sound, ...). A decoder file adds *extra* panes on top of the 328 standard set provided by the active programmer definition (which 329 lives in ``xml/programmers/*.xml`` — a separate loader). 330 331 ``items`` is the ordered list of variable ``item`` names referenced 332 by ``<display item="...">`` anywhere in the pane. Change-tracking 333 and pane-scoped read/write queues iterate this list. 334 335 ``raw_element`` preserves the underlying lxml element so a later 336 rendering step can reproduce the ``<column>``/``<row>``/``<label>``/ 337 ``<separator>`` structure faithfully. 338 """ 339 model_config = ConfigDict(arbitrary_types_allowed=True) 340 341 name: str = "" 342 # ``xml:lang`` → localised pane name. The default name (no lang 343 # attribute) stays in ``name``; other locales go here so a future 344 # UI can pick the user's language. 345 name_by_lang: dict[str, str] = Field(default_factory=dict) 346 # Ordered list of variable ``item`` names referenced by <display> 347 # elements anywhere inside the pane. Duplicates are preserved 348 # because JMRI's UI shows one row per <display>, not one row per 349 # unique item. 350 items: list[str] = Field(default_factory=list) 351 # ``<cvtable/>`` marker anywhere in the pane — the CVs tab that 352 # DecoderPro's "CVs" pane uses to expose raw CV read/write. 353 has_cvtable: bool = False 354 # ``<dccaddress/>`` marker — a first-class widget shown by the 355 # Basic pane at the top. 356 has_dcc_address: bool = False 357 # ``<speedtable/>`` widget marker. JMRI's Speed Table pane doesn't 358 # list per-step ``<display>`` items; instead the whole 28-step 359 # curve is drawn by this widget. When set, the render layer expands 360 # the decoder's SPEED_TABLE variable into per-step CV rows. 361 has_speed_table: bool = False 362 # ``<fnmapping/>`` marker — the Function Map pane's checkbox 363 # matrix. Like ``<cvtable/>``, it doesn't reference variables 364 # via ``<display>``; the renderer collects ``Fx controls output N`` 365 # variables from the decoder's variable list. 366 has_fnmapping: bool = False 367 # Raw pane element for step-3 rich rendering. 368 raw_element: etree._Element | None = None 369 370 371class DecoderFile(BaseModel): 372 """One ``xml/decoders/*.xml`` file parsed into a model. 373 374 The ``raw_xml`` field is the original bytes (for byte-level operations); 375 individual element access is via the model attributes. ``includes`` 376 captures any ``xi:include`` references for later resolution. 377 """ 378 model_config = ConfigDict(arbitrary_types_allowed=True) 379 380 path: pathlib.Path | None = None 381 family: DecoderFamily | None = None 382 variables: list[DecoderVariable] = Field(default_factory=list) 383 # Decoder-declared programmer panes, in source order. Empty for 384 # decoder files that don't declare any — the programmer definition 385 # provides the standard tab set separately. 386 panes: list[DecoderPane] = Field(default_factory=list) 387 includes: list[str] = Field(default_factory=list) 388 389 390XINCLUDE_NS = "{http://www.w3.org/2001/XInclude}" 391XML_LANG_ATTR = "{http://www.w3.org/XML/1998/namespace}lang" 392 393 394def load_decoder_bytes( 395 data: bytes, 396 path: pathlib.Path | None = None, 397 *, 398 xml_root: pathlib.Path | None = None, 399) -> DecoderFile: 400 """Parse decoder XML bytes, optionally resolving ``xi:include`` targets. 401 402 *xml_root* is the directory that ``http://jmri.org/xml/`` hrefs map 403 to on disk — typically ``<jmri install>/xml``. When supplied, 404 ``xi:include`` elements are resolved in-place before parsing, so 405 variables + panes that live in shared fragments (JMRI's TCS, 406 Digitrax, ESU decoders all do this) come through as real 407 :class:`DecoderVariable` / :class:`DecoderPane` entries instead of 408 silently vanishing. When *xml_root* is ``None``, xi:includes stay 409 untouched — matches the older no-op behaviour tests rely on. 410 """ 411 parser = etree.XMLParser(remove_blank_text=False, resolve_entities=False) 412 root = etree.fromstring(data, parser=parser) 413 if xml_root is not None: 414 _resolve_xincludes(root, xml_root) 415 return _parse_decoder_root(root, path) 416 417 418def load_decoder( 419 path: str | pathlib.Path, 420 *, 421 xml_root: pathlib.Path | None = None, 422) -> DecoderFile: 423 """Load and parse a decoder definition file. 424 425 When *xml_root* is omitted, it's inferred from *path* by walking 426 upward looking for a ``decoders/`` ancestor — the standard JMRI 427 layout is ``<xml_root>/decoders/<file>.xml``. If no ``decoders/`` 428 ancestor is found, xi:include resolution is skipped (fragment paths 429 would be uncomputable). 430 """ 431 p = pathlib.Path(path) 432 if xml_root is None: 433 xml_root = _infer_xml_root(p) 434 return load_decoder_bytes(p.read_bytes(), p, xml_root=xml_root) 435 436 437def _infer_xml_root(path: pathlib.Path) -> pathlib.Path | None: 438 """Return the directory ``http://jmri.org/xml/`` should map to. 439 440 Follows JMRI's convention: decoder files live at 441 ``<xml_root>/decoders/[<mfg>/]<file>.xml``, so the xml root is the 442 parent of the first ancestor named ``decoders``. Returns ``None`` 443 when no such ancestor exists (e.g. when tests write decoder XML to 444 a bare tmp dir). 445 """ 446 for ancestor in path.parents: 447 if ancestor.name == "decoders": 448 return ancestor.parent 449 return None 450 451 452_JMRI_URL_PREFIX = "http://jmri.org/xml/" 453 454 455def _resolve_xincludes( 456 root: etree._Element, 457 xml_root: pathlib.Path, 458 _seen: set[pathlib.Path] | None = None, 459) -> None: 460 """Splice ``xi:include`` targets into *root*, in place. 461 462 JMRI's fragment convention: an included file's root element carries 463 the same tag as the include's parent (e.g. a ``<variables>`` 464 fragment is included inside a ``<variables>`` element). In that 465 case we splice the fragment's children in place of the 466 ``<xi:include>``. When the tags differ (a whole ``<pane>`` fragment 467 included at the ``<decoder-config>`` level), we substitute the 468 fragment root itself. 469 470 Recurses into resolved fragments so nested includes (fragment A 471 including fragment B) also come through. Cycle-guarded via *_seen*. 472 473 Unresolvable hrefs (missing file, non-JMRI URL prefix) are left as 474 dangling ``<xi:include>`` placeholders — subsequent parsing sees 475 no children from them, matches the "not-loaded" fallback path. 476 """ 477 if _seen is None: 478 _seen = set() 479 # ``list(...)`` because we mutate the tree while iterating. 480 for inc in list(root.iter(f"{XINCLUDE_NS}include")): 481 href = inc.get("href", "") 482 if not href.startswith(_JMRI_URL_PREFIX): 483 continue 484 rel = href[len(_JMRI_URL_PREFIX):] 485 fragment_path = (xml_root / rel).resolve() 486 if not fragment_path.is_file(): 487 _log.debug( 488 "xi:include target not found: %s (from href=%s)", 489 fragment_path, href, 490 ) 491 continue 492 if fragment_path in _seen: 493 # Cycle — the fragment we're about to load is already 494 # somewhere in the current recursion path. Bail rather 495 # than recurse forever. 496 _log.warning("xi:include cycle detected at %s", fragment_path) 497 continue 498 try: 499 parser = etree.XMLParser( 500 remove_blank_text=False, resolve_entities=False, 501 ) 502 fragment_root = etree.parse(str(fragment_path), parser=parser).getroot() 503 except (etree.XMLSyntaxError, OSError) as e: 504 _log.debug("xi:include failed to load %s: %s", fragment_path, e) 505 continue 506 # Track path membership only during the descent into this 507 # fragment. Once we return, drop it so subsequent SIBLING 508 # xi:includes of the same fragment aren't misidentified as 509 # cycles. Real JMRI decoder files fan out to the same enum 510 # fragment dozens of times (each Extended Function Map row 511 # xi:includes ECOenumFnMap.xml, etc.) and treating those as 512 # cycles silently drops enum choices from every occurrence 513 # past the first. 514 _seen.add(fragment_path) 515 try: 516 _resolve_xincludes(fragment_root, xml_root, _seen) 517 finally: 518 _seen.discard(fragment_path) 519 parent = inc.getparent() 520 if parent is None: 521 continue 522 idx = list(parent).index(inc) 523 parent.remove(inc) 524 if fragment_root.tag == parent.tag: 525 # Same-tag fragment → splice its children into the parent. 526 for i, child in enumerate(list(fragment_root)): 527 parent.insert(idx + i, child) 528 else: 529 # Fragment root gets substituted whole. 530 parent.insert(idx, fragment_root) 531 532 533def load_pane_bytes(data: bytes) -> DecoderPane: 534 """Parse a standalone ``<pane>...</pane>`` fragment file. 535 536 JMRI ships reusable pane definitions under paths like 537 ``xml/decoders/digitrax/PaneDigitrax.xml`` with ``<pane>`` as the 538 root element. Decoder files pull them in via ``xi:include``. 539 """ 540 parser = etree.XMLParser(remove_blank_text=False, resolve_entities=False) 541 root = etree.fromstring(data, parser=parser) 542 if root.tag != "pane": 543 raise ValueError(f"expected <pane> root, got <{root.tag}>") 544 return _parse_pane(root) 545 546 547def load_pane(path: str | pathlib.Path) -> DecoderPane: 548 return load_pane_bytes(pathlib.Path(path).read_bytes()) 549 550 551def iter_decoder_files(dir_path: str | pathlib.Path) -> Iterator[DecoderFile]: 552 """Walk *dir_path*, yielding one :class:`DecoderFile` per ``.xml``. 553 554 JMRI's ``xml/decoders/`` tree contains many non-``<decoder-config>`` 555 XML files alongside the decoder definitions — NMRA CV lists, family 556 index files, function-label fragments included via xi:include, etc. 557 Anything whose root isn't ``<decoder-config>`` (raising ValueError 558 from :func:`_parse_decoder_root`) or whose XML is malformed is 559 skipped so the caller's enumeration completes across the whole tree 560 instead of aborting on the first non-decoder file. 561 """ 562 root_path = pathlib.Path(dir_path) 563 for xml in sorted(root_path.rglob("*.xml")): 564 try: 565 yield load_decoder(xml) 566 except etree.XMLSyntaxError as e: 567 _log.debug("iter_decoder_files: XML parse failure %s: %s", xml, e) 568 continue 569 except ValueError as e: 570 # Non-decoder XML file (index/fragment/NMRA-CVs list). These 571 # live in the same tree by JMRI convention; just skip. 572 _log.debug("iter_decoder_files: skipping %s (%s)", xml, e) 573 continue 574 except OSError as e: 575 _log.warning("iter_decoder_files: cannot read %s: %s", xml, e) 576 continue 577 578 579def load_all_decoders(dir_path: str | pathlib.Path) -> list[DecoderFile]: 580 return list(iter_decoder_files(dir_path)) 581 582 583# ----- lightweight index scan ------------------------------------------ 584# 585# JMRI ships 2000+ decoder XMLs; parsing every variable + pane on server 586# start is too slow for interactive use (~30-60 s on a Pi). The scanner 587# below extracts only the identity fields we need to look a file up by 588# (family, model) — no variables, no panes, no pydantic. Full parse is 589# deferred until the user opens the programmer for a specific loco. 590 591 592class DecoderIndexEntry(BaseModel): 593 """One (family, model) tuple + the file it lives in. 594 595 ``mfg`` comes from the ``<family mfg=…>`` attribute. Both ``family`` 596 and ``mfg`` are shared across every entry from the same file; only 597 ``model`` varies. ``source_file`` is the path to the decoder XML — 598 caller reads it via :func:`load_decoder` when a full definition is 599 actually needed. 600 601 ``low_version_id`` / ``high_version_id`` bracket which CV7 values 602 this model accepts. Model-level ``lowVersionID``/``highVersionID`` 603 override family-level defaults; both are optional so JMRI files that 604 don't declare them stay wildcard (0..255). 605 606 ``product_id`` is the ``<model productID="…"/>`` attribute — a 607 manufacturer-specific identifier that lets identify narrow past 608 the family. For SoundTraxx, ``productID`` is derived from CVs 253, 609 255, and 256 (see the identify route). 610 """ 611 model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) 612 613 family: str 614 mfg: str 615 model: str 616 source_file: pathlib.Path 617 low_version_id: int = 0 618 high_version_id: int = 255 619 product_id: str = "" 620 621 622def iter_decoder_index(dir_path: str | pathlib.Path) -> Iterator[DecoderIndexEntry]: 623 """Yield one :class:`DecoderIndexEntry` per (family, model) pair. 624 625 A single decoder XML file typically declares one family with many 626 model rows, so one file emits multiple entries. Non-decoder XML 627 files under the same tree (NMRA CV lists, xi:include fragments, 628 programmer definitions) are skipped silently. 629 630 This function does *not* validate the file — it only touches the 631 ``<family>`` and ``<model>`` elements. Anything else in the file 632 (variables, panes, xi:includes) is ignored. Callers that need the 633 full definition should call :func:`load_decoder` on the returned 634 ``source_file`` path. 635 """ 636 root_path = pathlib.Path(dir_path) 637 parser = etree.XMLParser(remove_blank_text=False, resolve_entities=False) 638 for xml in sorted(root_path.rglob("*.xml")): 639 try: 640 tree = etree.parse(str(xml), parser=parser) 641 except etree.XMLSyntaxError as e: 642 _log.debug("iter_decoder_index: XML parse failure %s: %s", xml, e) 643 continue 644 except OSError as e: 645 _log.warning("iter_decoder_index: cannot read %s: %s", xml, e) 646 continue 647 root = tree.getroot() 648 if root.tag != "decoder-config": 649 # Not a decoder file (index / programmer / fragment). Skip. 650 continue 651 # JMRI decoder files: one <family> element under <decoder>. Use 652 # descendant search because a few files nest differently and 653 # xi:include can add layers. 654 family_el = root.find(".//family") 655 if family_el is None: 656 continue 657 family_name = family_el.get("name", "").strip() 658 mfg = family_el.get("mfg", "").strip() 659 # Family-level version range — used as the default when a 660 # model doesn't declare its own. Missing attrs stay wildcard. 661 def _int_or(elem: "etree._Element", attr: str, fallback: int) -> int: 662 raw = elem.get(attr, "") 663 try: 664 return int(raw) 665 except (TypeError, ValueError): 666 return fallback 667 fam_low = _int_or(family_el, "lowVersionID", 0) 668 fam_high = _int_or(family_el, "highVersionID", 255) 669 for m in family_el.findall("model"): 670 model_name = m.get("model", "").strip() 671 if not model_name or not family_name: 672 # An empty family or model name would poison the index 673 # key. Skip; log at debug so a malformed file surfaces 674 # without spamming warnings on every load. 675 _log.debug( 676 "iter_decoder_index: skipping model in %s: family=%r model=%r", 677 xml, family_name, model_name, 678 ) 679 continue 680 yield DecoderIndexEntry( 681 family=family_name, mfg=mfg, 682 model=model_name, source_file=xml, 683 low_version_id=_int_or(m, "lowVersionID", fam_low), 684 high_version_id=_int_or(m, "highVersionID", fam_high), 685 product_id=m.get("productID", "").strip(), 686 ) 687 688 689# ----- internal -------------------------------------------------------------- 690 691 692def _parse_decoder_root(root: etree._Element, path: pathlib.Path | None) -> DecoderFile: 693 if root.tag != "decoder-config": 694 raise ValueError(f"expected <decoder-config>, got <{root.tag}>") 695 696 decoder = root.find("decoder") 697 if decoder is None: 698 return DecoderFile(path=path) 699 700 family_elem = decoder.find("family") 701 family = _parse_family(family_elem) if family_elem is not None else None 702 703 variables_elem = decoder.find("variables") 704 variables = list(_iter_variables(variables_elem)) if variables_elem is not None else [] 705 706 # Panes live at the ``<decoder-config>`` level, alongside ``<decoder>``. 707 # A decoder may declare zero or more of them; standard tabs (Basic, 708 # Motor, ...) come from the programmer definition and are loaded 709 # separately. 710 panes = [_parse_pane(p) for p in root.findall("pane")] 711 712 includes = [ 713 elem.get("href", "") 714 for elem in root.iter(f"{XINCLUDE_NS}include") 715 if elem.get("href") 716 ] 717 718 return DecoderFile( 719 path=path, family=family, variables=variables, 720 panes=panes, includes=includes, 721 ) 722 723 724def _parse_pane(elem: etree._Element) -> DecoderPane: 725 """Turn a ``<pane>`` element into a :class:`DecoderPane`. 726 727 JMRI's pane structure is nested (``<column>`` / ``<row>`` containing 728 ``<display>`` / ``<label>`` / ``<separator>`` / ``<cvtable>`` / 729 ``<dccaddress>`` / further ``<column>``/``<row>``), but for our 730 change-tracking + queue-scoping purposes we care about the flat 731 list of ``<display item="...">`` references. ``elem.iter()`` walks 732 the whole subtree, giving us that flat list in document order. 733 Rich layout rendering (step 3) works off the preserved 734 ``raw_element``. 735 """ 736 default_name = "" 737 name_by_lang: dict[str, str] = {} 738 for name_elem in elem.findall("name"): 739 text = (name_elem.text or "").strip() 740 lang = name_elem.get(XML_LANG_ATTR, "") 741 if lang: 742 name_by_lang[lang] = text 743 elif not default_name: 744 default_name = text 745 746 items = [ 747 d.get("item", "") 748 for d in elem.iter("display") 749 if d.get("item") 750 ] 751 has_cvtable = elem.find(".//cvtable") is not None 752 has_dcc_address = elem.find(".//dccaddress") is not None 753 has_fnmapping = elem.find(".//fnmapping") is not None 754 # JMRI's Speed Table pane widget: catch both the standard 755 # ``<speedtable/>`` element and any case variant / adjacent widget 756 # whose tag name starts with "speedtable" (JMRI has shipped both 757 # ``<speedtable/>`` and ``<speedTable/>`` across versions, plus 758 # helper widgets like ``<speedTableVarSlider/>``). Whenever any of 759 # those is present, the render layer expands the decoder's 760 # SPEED_TABLE variable into per-step CV rows. 761 has_speed_table = any( 762 isinstance(d.tag, str) and d.tag.lower().startswith("speedtable") 763 for d in elem.iter() 764 ) 765 766 return DecoderPane( 767 name=default_name, 768 name_by_lang=name_by_lang, 769 items=items, 770 has_cvtable=has_cvtable, 771 has_dcc_address=has_dcc_address, 772 has_speed_table=has_speed_table, 773 has_fnmapping=has_fnmapping, 774 raw_element=elem, 775 ) 776 777 778# ----- rich pane layout tree -------------------------------------------- 779 780 781# Container elements — nest child nodes. 782_LAYOUT_CONTAINER_TAGS = {"column", "row", "group"} 783# Widget leaf elements the renderer knows how to draw. 784_LAYOUT_LEAF_TAGS = { 785 "display", "label", "separator", 786 "cvtable", "speedtable", "dccaddress", "fnmapping", 787} 788# Grid layout — ``<grid>`` wraps ``<griditem gridx="…" gridy="…">`` 789# children, each of which is itself a container of layout nodes. 790_LAYOUT_GRID_TAG = "grid" 791_LAYOUT_GRIDITEM_TAG = "griditem" 792 793 794def pane_layout_tree(pane: "DecoderPane") -> dict: 795 """Build a nested tree describing the pane's authored layout. 796 797 Walks :attr:`DecoderPane.raw_element` and emits a tree of dicts 798 the renderer can iterate recursively. Recognises JMRI's usual 799 layout primitives — ``<column>``, ``<row>``, ``<group>``, 800 ``<display>``, ``<label>``, ``<separator>``, ``<cvtable>``, 801 ``<speedtable>``, ``<dccaddress>``. 802 803 Nodes carry a ``type`` key and either a ``children`` list 804 (containers) or type-specific attributes (leaves): 805 806 * ``{"type": "column", "children": [...]}`` 807 * ``{"type": "display", "item": "Vstart"}`` 808 * ``{"type": "label", "text": "Speed control"}`` 809 * ``{"type": "separator"}`` 810 811 Any element the renderer doesn't recognise (rare — hand-authored 812 decoder files sometimes have quirks) is silently skipped, keeping 813 the rest of the tree renderable. 814 815 When *pane* has no ``raw_element`` (e.g. constructed in a test 816 from a bare ``DecoderPane(name="X", items=[…])``), the returned 817 tree is a flat column of the pane's items — matches what the 818 legacy flat-table renderer showed and keeps tests uncoupled from 819 XML shape. 820 """ 821 if pane.raw_element is None: 822 return _dedupe_display_nodes({ 823 "type": "column", 824 "children": [ 825 {"type": "display", "item": item} 826 for item in pane.items 827 ], 828 }) 829 root_children = list(_layout_children(pane.raw_element)) 830 # If the pane has no explicit container at the top level, wrap in 831 # a column so the renderer always starts from a container node. 832 if len(root_children) == 1 and root_children[0]["type"] in _LAYOUT_CONTAINER_TAGS: 833 return _dedupe_display_nodes(root_children[0]) 834 return _dedupe_display_nodes( 835 {"type": "column", "children": root_children}, 836 ) 837 838 839def _dedupe_display_nodes(tree: dict) -> dict: 840 """Walk *tree* and drop repeat ``<display item="X">`` leaves. 841 842 JMRI's pane XML sometimes references the same variable twice — 843 typically for two widget presentations (numeric input + slider), 844 or because two ``<column>`` blocks each pull it in. Our renderer 845 only knows how to draw one widget per variable, so a naive walk 846 produces duplicate rows side-by-side in the same column, or the 847 same variable in two adjacent columns. Track seen items across 848 the WHOLE tree (not just per container) and drop later 849 occurrences. 850 851 Mutates the tree in place and returns it for chaining. 852 """ 853 seen: set[str] = set() 854 855 def prune(node: dict) -> dict | None: 856 if node.get("type") == "display": 857 item = node.get("item", "") 858 if not item or item in seen: 859 return None 860 seen.add(item) 861 return node 862 if "children" in node: 863 pruned: list[dict] = [] 864 for child in node["children"]: 865 kept = prune(child) 866 if kept is not None: 867 pruned.append(kept) 868 node["children"] = pruned 869 return node 870 871 prune(tree) 872 return tree 873 874 875def _layout_children(elem: "etree._Element") -> Iterator[dict]: 876 """Iterate the layout-relevant children of *elem* as tree nodes.""" 877 for child in elem: 878 if not isinstance(child.tag, str): 879 # Comments, processing instructions — skip. 880 continue 881 node = _layout_node(child) 882 if node is not None: 883 yield node 884 885 886def _layout_griditem(elem: "etree._Element") -> dict | None: 887 """Turn a ``<griditem>`` into a placement + children node. 888 889 JMRI grid items use Swing's ``GridBagLayout`` attribute names. 890 We preserve the ones the renderer needs and drop the rest: 891 892 * ``gridx`` / ``gridy`` — 0-based cell coordinates. 893 * ``gridwidth`` / ``gridheight`` — spans. JMRI also uses the 894 string ``REMAINDER`` for "span to the end of the row/column". 895 * ``anchor`` — Swing anchor constant (``LINE_END``, ``CENTER``, 896 etc.); we map the common ones to CSS text-alignment. 897 """ 898 if not isinstance(elem.tag, str) or elem.tag != _LAYOUT_GRIDITEM_TAG: 899 return None 900 def _int_attr(name: str) -> int | None: 901 raw = elem.get(name) 902 if raw is None or raw == "": 903 return None 904 try: 905 return int(raw) 906 except ValueError: 907 return None 908 return { 909 "type": "griditem", 910 "gridx": _int_attr("gridx"), 911 "gridy": _int_attr("gridy"), 912 "gridwidth": elem.get("gridwidth") or "", 913 "gridheight": elem.get("gridheight") or "", 914 "anchor": elem.get("anchor") or "", 915 "children": list(_layout_children(elem)), 916 } 917 918 919def _layout_node(elem: "etree._Element") -> dict | None: 920 """Turn one XML element into a layout tree node, or None to skip. 921 922 Ordering matters for the renderer, so we preserve document order 923 and only drop elements the renderer wouldn't know what to do with 924 (rare metadata like ``<name>``, ``<qualifier>`` — those are 925 consumed at parse time or belong elsewhere). 926 """ 927 tag = elem.tag 928 if not isinstance(tag, str): 929 return None 930 if tag in _LAYOUT_CONTAINER_TAGS: 931 return { 932 "type": tag, 933 "children": list(_layout_children(elem)), 934 } 935 if tag == _LAYOUT_GRID_TAG: 936 return { 937 "type": "grid", 938 "children": [ 939 node for node in (_layout_griditem(c) for c in elem) 940 if node is not None 941 ], 942 } 943 if tag == "display": 944 item = elem.get("item", "") 945 if not item: 946 return None 947 return { 948 "type": "display", 949 "item": item, 950 # ``format`` decides how the widget renders: 951 # "checkbox" collapses an int variable to a bool checkbox, 952 # "" / absent means the variable's natural widget (enum 953 # dropdown, numeric input, …). Preserving the raw attribute 954 # here lets the template pick without another lookup. 955 "format": elem.get("format", ""), 956 # ``label=""`` on a display suppresses the widget's own 957 # label — used inside grid cells where the label is in a 958 # neighbouring cell instead. 959 "label_override": elem.get("label"), 960 } 961 if tag == "label": 962 # ``<label>text</label>`` or ``<label><text>text</text></label>``. 963 # Localised variants match the same rule as variable labels. 964 text = _pick_localized_text(elem, "text") or (elem.text or "").strip() 965 return {"type": "label", "text": text} 966 if tag == "separator": 967 return {"type": "separator"} 968 if tag == "cvtable": 969 return {"type": "cvtable"} 970 if tag == "dccaddress": 971 return {"type": "dccaddress"} 972 if tag == "fnmapping": 973 return {"type": "fnmapping"} 974 # ``<speedtable/>`` and any case/tag variant that starts with 975 # "speedtable" (older JMRI files shipped ``<speedTable>``, 976 # ``<speedTableVarSlider>``, etc.). 977 if tag.lower().startswith("speedtable"): 978 return {"type": "speedtable"} 979 return None 980 981 982def _parse_family(elem: etree._Element) -> DecoderFamily: 983 models = [_parse_model(m) for m in elem.findall("model")] 984 outputs = [ 985 DecoderOutput(name=o.get("name", ""), label=o.get("label", "")) 986 for o in elem.findall("output") 987 ] 988 return DecoderFamily( 989 name=elem.get("name", ""), 990 mfg=elem.get("mfg", ""), 991 models=models, 992 outputs=outputs, 993 ) 994 995 996def _parse_model(elem: etree._Element) -> DecoderModel: 997 outputs = [ 998 DecoderOutput(name=o.get("name", ""), label=o.get("label", "")) 999 for o in elem.findall("output") 1000 ] 1001 protocols = [ 1002 (p.text or "").strip() 1003 for p in elem.findall("protocols/protocol") 1004 if (p.text or "").strip() 1005 ] 1006 size = elem.find("size") 1007 return DecoderModel( 1008 model=elem.get("model", ""), 1009 connector=elem.get("connector", ""), 1010 show_empty_panes=elem.get("showEmptyPanes"), 1011 max_input_volts=elem.get("maxInputVolts"), 1012 max_motor_current=elem.get("maxMotorCurrent"), 1013 outputs=outputs, 1014 protocols=protocols, 1015 size_length=size.get("length") if size is not None else None, 1016 size_width=size.get("width") if size is not None else None, 1017 size_height=size.get("height") if size is not None else None, 1018 size_units=size.get("units") if size is not None else None, 1019 product_id=elem.get("productID", ""), 1020 ) 1021 1022 1023def _parse_default_int(raw: str | None) -> int | None: 1024 """Coerce a ``<variable default="…">`` string to an int. 1025 1026 Real JMRI decoder files use plain decimal ints for the ``default`` 1027 attribute. A handful of hand-authored files use ``0x…`` hex — accept 1028 that too via ``base=0`` (auto-detects). Anything else (blank, 1029 missing, malformed) returns ``None`` so downstream can use its 1030 "no known default" fallback path. 1031 """ 1032 if raw is None: 1033 return None 1034 raw = raw.strip() 1035 if not raw: 1036 return None 1037 try: 1038 return int(raw, 0) 1039 except (TypeError, ValueError): 1040 return None 1041 1042 1043def _pick_localized_text( 1044 elem: etree._Element, tag: str, preferred_lang: str = "en", 1045) -> str: 1046 """Pick the best localized text out of ``<tag>`` children. 1047 1048 JMRI decoder XMLs sometimes carry the same descriptive text in 1049 multiple languages via ``<label xml:lang="…">…</label>`` — the 1050 same variable can have an English, Italian, French, German, and 1051 Dutch label. The user's UI is English; we shouldn't silently render 1052 the Italian one just because ``elem.find(tag)`` happened to hit it 1053 first. 1054 1055 Priority order: 1056 1057 1. The ``<tag>`` element with ``xml:lang="<preferred_lang>"``. 1058 2. The ``<tag>`` element with NO ``xml:lang`` attribute (JMRI's 1059 convention: the unqualified element is the default English 1060 text — same rule the ``<pane><name>`` parser uses). 1061 3. Any other ``<tag>`` element (last-resort fallback). 1062 4. Empty string. 1063 1064 Preserves inline markup like ``<br/>`` and ``<b>``: JMRI tooltips 1065 routinely contain such tags for line breaks and emphasis. We serialize 1066 the element's content back to a string, stripping only the wrapping 1067 ``<tag>…</tag>`` — the caller can render it as HTML. 1068 """ 1069 children = elem.findall(tag) 1070 if not children: 1071 return "" 1072 1073 def _inner_html(node: etree._Element) -> str: 1074 out = node.text or "" 1075 for child in node: 1076 out += etree.tostring(child, encoding="unicode") 1077 return out.strip() 1078 1079 preferred = None 1080 unqualified = None 1081 fallback = None 1082 for c in children: 1083 lang = c.get(XML_LANG_ATTR, "") 1084 if lang == preferred_lang and preferred is None: 1085 preferred = c 1086 elif not lang and unqualified is None: 1087 unqualified = c 1088 elif fallback is None: 1089 fallback = c 1090 # lxml Elements are falsy when they have no children — use 1091 # explicit ``is not None`` checks so we don't accidentally skip an 1092 # empty ``<label></label>``. 1093 picked = preferred 1094 if picked is None: 1095 picked = unqualified 1096 if picked is None: 1097 picked = fallback 1098 if picked is None: 1099 picked = children[0] 1100 return _inner_html(picked) 1101 1102 1103def _iter_variables(elem: etree._Element) -> Iterable[DecoderVariable]: 1104 """Yield CV variables, skipping XInclude pointers (captured separately). 1105 1106 Populates the step-2 typed fields (``kind`` + type-specific structure) 1107 based on which value-type child element the variable declares. If 1108 none is present (rare — usually a placeholder), ``kind`` stays 1109 :data:`VariableKind.UNKNOWN` and the variable acts as an opaque byte. 1110 """ 1111 for v in elem.findall("variable"): 1112 label_text = _pick_localized_text(v, "label") 1113 tooltip_text = _pick_localized_text(v, "tooltip") 1114 # ``<qualifier>`` (or ``<qualifiers><qualifier>``): runtime 1115 # visibility gates. Each holds a ``variableref`` + ``relation`` + 1116 # ``value`` triple. Collect them flat so the caller can AND them. 1117 qualifier_elems = list(v.findall("qualifier")) 1118 qualifier_elems.extend(v.findall("qualifiers/qualifier")) 1119 qualifier_dicts: list[dict[str, str]] = [] 1120 for q in qualifier_elems: 1121 vref = q.find("variableref") 1122 rel = q.find("relation") 1123 val = q.find("value") 1124 if vref is None or rel is None: 1125 continue 1126 qualifier_dicts.append({ 1127 "variableref": (vref.text or "").strip(), 1128 "relation": (rel.text or "").strip(), 1129 "value": (val.text or "").strip() if val is not None else "", 1130 }) 1131 # Mask may be on <variable> directly or inside <splitVal>. 1132 split_elem = _first_child(v, ("splitVal", "splitHexVal", "splitDateTimeVal")) 1133 mask = v.get("mask") or (split_elem.get("upperMask") if split_elem is not None else None) 1134 min_val = v.get("min") or (split_elem.get("min") if split_elem is not None else None) 1135 max_val = v.get("max") or (split_elem.get("max") if split_elem is not None else None) 1136 1137 cv_attr = v.get("CV", "") 1138 kind, kind_extras = _classify_value_type(v) 1139 1140 # Populate ``split`` for ANY variable whose CV attribute uses 1141 # dotted notation (``16.1.275`` = indexed CV, ``275:4`` = 1142 # 4-byte split, ``17.18`` = long-address pair). The value-type 1143 # child (decVal / hexVal / enumVal / …) doesn't necessarily 1144 # signal indexed access — a plain ``<decVal>`` on 1145 # ``CV="16.1.275"`` is a decimal variable that happens to sit 1146 # at bank/index 16/1, base CV 275. Without this fallback, the 1147 # write path would send the byte to CV 275 under the current 1148 # (uncontrolled) bank/index — corrupting unrelated state. 1149 parsed_split = kind_extras.get("split") 1150 if parsed_split is None and cv_attr and ( 1151 "." in cv_attr or ":" in cv_attr 1152 ): 1153 parsed_split = SplitCv.parse(cv_attr) 1154 yield DecoderVariable( 1155 CV=cv_attr, 1156 item=v.get("item", ""), 1157 default=_parse_default_int(v.get("default")), 1158 mask=mask, 1159 min=min_val, 1160 max=max_val, 1161 label=label_text, 1162 ops_only=(v.get("opsOnly", "no").lower() == "yes"), 1163 kind=kind, 1164 bit_mask=CvBitMask.parse(mask), 1165 enum_choices=kind_extras.get("enum_choices", []), 1166 composite_choices=kind_extras.get("composite_choices", []), 1167 split=parsed_split, 1168 constant_value=kind_extras.get("constant_value"), 1169 speed_table_entries=kind_extras.get("speed_table_entries", 28), 1170 read_only=(v.get("readOnly", "no").lower() == "yes"), 1171 write_only=(v.get("writeOnly", "no").lower() == "yes"), 1172 tooltip=tooltip_text or v.get("tooltip", ""), 1173 include_product_ids=[ 1174 p.strip() for p in v.get("include", "").split(",") 1175 if p.strip() 1176 ], 1177 exclude_product_ids=[ 1178 p.strip() for p in v.get("exclude", "").split(",") 1179 if p.strip() 1180 ], 1181 qualifiers=qualifier_dicts, 1182 ) 1183 1184 1185def _first_child(elem: etree._Element, tags: tuple[str, ...]) -> etree._Element | None: 1186 for tag in tags: 1187 child = elem.find(tag) 1188 if child is not None: 1189 return child 1190 return None 1191 1192 1193# Map value-type child element tag → VariableKind. 1194_KIND_BY_TAG = { 1195 "decVal": VariableKind.DECIMAL, 1196 "hexVal": VariableKind.HEXADECIMAL, 1197 "enumVal": VariableKind.ENUM, 1198 "speedTable": VariableKind.SPEED_TABLE, 1199 "shortAddressVal": VariableKind.SHORT_ADDRESS, 1200 "longAddressVal": VariableKind.LONG_ADDRESS, 1201 "splitVal": VariableKind.SPLIT, 1202 "splitHexVal": VariableKind.SPLIT_HEX, 1203 "splitDateTimeVal": VariableKind.SPLIT_DATETIME, 1204 "compositeVal": VariableKind.COMPOSITE, 1205 "constant": VariableKind.CONSTANT, 1206} 1207 1208 1209def _classify_value_type(v: etree._Element) -> tuple[VariableKind, dict]: 1210 """Look at the value-type children of ``<variable>`` and pick a kind. 1211 1212 Returns ``(kind, extras)`` where ``extras`` is a dict of type-specific 1213 parsed structure the caller drops into the :class:`DecoderVariable` 1214 constructor. 1215 """ 1216 cv_attr = v.get("CV", "") 1217 for tag, kind in _KIND_BY_TAG.items(): 1218 child = v.find(tag) 1219 if child is None: 1220 continue 1221 if kind is VariableKind.ENUM: 1222 return kind, {"enum_choices": list(_iter_enum_choices(child))} 1223 if kind is VariableKind.COMPOSITE: 1224 return kind, {"composite_choices": list(_iter_composite_choices(child))} 1225 if kind in ( 1226 VariableKind.SPLIT, VariableKind.SPLIT_HEX, VariableKind.SPLIT_DATETIME, 1227 ): 1228 return kind, {"split": SplitCv.parse(cv_attr)} 1229 if kind is VariableKind.LONG_ADDRESS: 1230 # CV17.CV18 pair. If the caller wrote CV="17", we still 1231 # need to know we're touching CV18 too. 1232 split_cv = SplitCv.parse(cv_attr or "17.18") 1233 return kind, {"split": split_cv} 1234 if kind is VariableKind.CONSTANT: 1235 raw = child.get("value") 1236 try: 1237 cval = int(raw) if raw is not None else None 1238 except ValueError: 1239 cval = None 1240 return kind, {"constant_value": cval} 1241 if kind is VariableKind.SPEED_TABLE: 1242 # ``<speedTable entries="N"/>`` declares the number of 1243 # speed-step slots; NMRA default is 28 (CV67..CV94). 1244 raw = child.get("entries") 1245 try: 1246 entries = int(raw) if raw else 28 1247 except ValueError: 1248 entries = 28 1249 return kind, {"speed_table_entries": max(entries, 1)} 1250 # decVal / hexVal / shortAddressVal — no extras. 1251 return kind, {} 1252 return VariableKind.UNKNOWN, {} 1253 1254 1255def _iter_enum_choices(elem: etree._Element) -> Iterator[EnumChoice]: 1256 """Walk ``<enumVal>`` yielding one :class:`EnumChoice` per 1257 ``<enumChoice>``. Positional index becomes the wire value when 1258 the element has no explicit ``value`` attribute. 1259 """ 1260 for index, ec in enumerate(elem.findall("enumChoice")): 1261 raw = ec.get("value") 1262 try: 1263 value = int(raw) if raw is not None else index 1264 except ValueError: 1265 value = index 1266 # Default choice text is the ``choice`` attribute; localised 1267 # variants appear as <choice xml:lang="…">…</choice> children. 1268 default = ec.get("choice", "") 1269 by_lang: dict[str, str] = {} 1270 for c in ec.findall("choice"): 1271 text = (c.text or "").strip() 1272 lang = c.get(XML_LANG_ATTR, "") 1273 if lang: 1274 by_lang[lang] = text 1275 elif not default and text: 1276 default = text 1277 yield EnumChoice(value=value, choice=default, choice_by_lang=by_lang) 1278 1279 1280def _iter_composite_choices(elem: etree._Element) -> Iterator[CompositeChoice]: 1281 """Walk ``<compositeVal>`` yielding one :class:`CompositeChoice` per 1282 ``<compositeChoice>`` plus its ``<compositeSetting>`` rows.""" 1283 for cc in elem.findall("compositeChoice"): 1284 default = cc.get("choice", "") 1285 by_lang: dict[str, str] = {} 1286 for c in cc.findall("choice"): 1287 text = (c.text or "").strip() 1288 lang = c.get(XML_LANG_ATTR, "") 1289 if lang: 1290 by_lang[lang] = text 1291 elif not default and text: 1292 default = text 1293 settings = [] 1294 for cs in cc.findall("compositeSetting"): 1295 try: 1296 value = int(cs.get("value", "0")) 1297 except ValueError: 1298 value = 0 1299 settings.append( 1300 CompositeSetting(label=cs.get("label", ""), value=value) 1301 ) 1302 yield CompositeChoice( 1303 choice=default, 1304 choice_by_lang=by_lang, 1305 settings=settings, 1306 ) 1307 1308 1309# ----- variable encode / decode ------------------------------------------ 1310 1311 1312class UnsupportedVariableKind(ValueError): 1313 """Raised when a caller tries to encode/decode a variable whose kind 1314 doesn't have a byte-level round-trip yet (COMPOSITE fans out to 1315 sub-variables; SPEED_TABLE has custom widget semantics).""" 1316 1317 1318def variable_primary_cv(var: DecoderVariable) -> int: 1319 """The primary CV byte this variable reads/writes. 1320 1321 For split variables the primary CV is the low-address byte of the 1322 split. For simple variables it's just ``int(var.cv)``. Returns 0 1323 if the CV attribute isn't a plain integer we can parse — the 1324 caller should treat that as "not directly programmable". 1325 """ 1326 if var.split is not None: 1327 return var.split.primary_cv 1328 try: 1329 return int(var.cv) 1330 except ValueError: 1331 return 0 1332 1333 1334def variable_cv_list(var: DecoderVariable) -> list[int]: 1335 """Every CV byte this variable touches, low-address first. 1336 1337 * Single-byte variables → ``[primary_cv]`` 1338 * Long-address → ``[17, 18]`` 1339 * Split-CV — depends on ``byte_count`` on the parsed :class:`SplitCv`. 1340 """ 1341 if var.kind is VariableKind.LONG_ADDRESS: 1342 return [17, 18] 1343 if var.split is not None: 1344 return var.split.cv_list 1345 primary = variable_primary_cv(var) 1346 return [primary] if primary else [] 1347 1348 1349def variable_is_indexed(var: DecoderVariable) -> bool: 1350 """True when *var* uses ``bank.index.base`` indexed CV addressing. 1351 1352 Indexed access (Tsunami2 / ESU / newer NCE decoders) requires 1353 writing ``CV31=bank`` + ``CV32=index`` before every read or write 1354 to the base CV. The decoder maintains bank/index state internally 1355 and interprets subsequent CV257–CV511 accesses through that page. 1356 """ 1357 return var.split is not None and var.split.bank is not None 1358 1359 1360def variable_store_key(var: DecoderVariable, cv_number: int) -> str: 1361 """Return the roster ``<CVvalue name="…">`` key for *cv_number* under *var*. 1362 1363 JMRI's roster file writes each CV under a distinct key: 1364 1365 * Plain CV: ``"275"`` 1366 * Indexed CV: ``"16.1.275"`` — bank + index + base 1367 1368 So `16.1.275` and `275` coexist without colliding — they mean 1369 different CVs on the wire (same base but different decoder pages). 1370 """ 1371 if variable_is_indexed(var): 1372 return f"{var.split.bank}.{var.split.index or 0}.{cv_number}" 1373 return str(cv_number) 1374 1375 1376@dataclass(frozen=True) 1377class VariableWriteOp: 1378 """One physical CV write generated by :func:`variable_wire_write_plan`. 1379 1380 * ``wire_cv`` — actual CV number sent on the wire (bare integer; 1381 the DCC packet targets this CV under whatever bank/index the 1382 decoder currently has). 1383 * ``byte_value`` — 0..255 byte to write. 1384 * ``store_key`` — key to persist under in the roster's cv_values 1385 dict, or ``None`` when the op is bank/index setup (CV31/CV32) 1386 that shouldn't leave a per-write trace. 1387 """ 1388 wire_cv: int 1389 byte_value: int 1390 store_key: str | None 1391 1392 1393def variable_wire_write_plan( 1394 var: DecoderVariable, 1395 human_value: int, 1396 cv_values: dict[str, int], 1397) -> list[VariableWriteOp]: 1398 """Ordered wire writes to set *var* to *human_value*. 1399 1400 Indexed variables produce three-or-more ops: ``CV31=bank``, 1401 ``CV32=index``, then one or more base-CV writes. The setup ops 1402 carry ``store_key=None`` so callers can skip persisting them 1403 (they'd corrupt the roster's per-variable tracking otherwise). 1404 1405 Non-indexed variables produce the same wire writes as 1406 :func:`variable_apply_to_state` but wrapped in :class:`VariableWriteOp` 1407 with plain string keys (``"275"``) for round-trip storage. 1408 1409 Read-only / CONSTANT variables return an empty list. 1410 """ 1411 plain = variable_apply_to_state(var, human_value, _int_keyed(cv_values)) 1412 if not plain: 1413 return [] 1414 ops: list[VariableWriteOp] = [] 1415 if variable_is_indexed(var): 1416 assert var.split is not None # narrow for the type checker 1417 ops.append(VariableWriteOp(wire_cv=31, byte_value=var.split.bank or 0, store_key=None)) 1418 ops.append(VariableWriteOp(wire_cv=32, byte_value=var.split.index or 0, store_key=None)) 1419 for wire_cv, byte in plain.items(): 1420 ops.append(VariableWriteOp( 1421 wire_cv=wire_cv, byte_value=byte, 1422 store_key=variable_store_key(var, wire_cv), 1423 )) 1424 return ops 1425 1426 1427def variable_wire_read_plan(var: DecoderVariable) -> list[VariableWriteOp]: 1428 """Ordered wire ops to read *var*'s current bytes. 1429 1430 For plain variables: one entry per CV in :func:`variable_cv_list`. 1431 For indexed variables: ``CV31=bank`` + ``CV32=index`` setup ops 1432 (as writes with ``store_key=None``) then one read entry per base 1433 CV. Callers should send the setup writes and then read the 1434 ``wire_cv`` of any op whose ``store_key`` is non-None. 1435 """ 1436 if variable_is_indexed(var): 1437 assert var.split is not None 1438 ops: list[VariableWriteOp] = [ 1439 VariableWriteOp(wire_cv=31, byte_value=var.split.bank or 0, store_key=None), 1440 VariableWriteOp(wire_cv=32, byte_value=var.split.index or 0, store_key=None), 1441 ] 1442 for cv in variable_cv_list(var): 1443 ops.append(VariableWriteOp( 1444 wire_cv=cv, byte_value=0, # unused for reads 1445 store_key=variable_store_key(var, cv), 1446 )) 1447 return ops 1448 # Non-indexed: no setup, just read each CV. 1449 return [ 1450 VariableWriteOp(wire_cv=cv, byte_value=0, store_key=str(cv)) 1451 for cv in variable_cv_list(var) 1452 ] 1453 1454 1455def _int_keyed(cv_values: dict[str, int]) -> dict[int, int]: 1456 """Convert a string-keyed CV snapshot to int-keyed for legacy helpers. 1457 1458 Silently drops keys that aren't plain integers — indexed keys 1459 like ``"16.1.275"`` don't participate in bit-mask RMW anyway; 1460 those are handled per-byte by the write plan directly. 1461 """ 1462 out: dict[int, int] = {} 1463 for k, v in cv_values.items(): 1464 try: 1465 out[int(k)] = int(v) 1466 except (TypeError, ValueError): 1467 continue 1468 return out 1469 1470 1471def variable_extract_from_store( 1472 var: DecoderVariable, cv_values: dict[str, int], 1473) -> int: 1474 """Like :func:`variable_extract_from_state` but keyed by store keys. 1475 1476 * Plain variables look up ``str(primary_cv)``. 1477 * Indexed variables look up ``"{bank}.{index}.{cv}"`` — the same 1478 shape JMRI Java writes in the roster's ``<CVvalue name="…">``. 1479 * Multi-byte splits assemble big-endian from consecutive keys. 1480 1481 Returns 0 when a key is missing (the caller should overlay a 1482 catalog default separately). 1483 """ 1484 if var.kind is VariableKind.CONSTANT: 1485 return var.constant_value or 0 1486 if var.kind is VariableKind.LONG_ADDRESS: 1487 high = int(cv_values.get("17", 0)) & 0x3F 1488 low = int(cv_values.get("18", 0)) & 0xFF 1489 return (high << 8) | low 1490 if var.kind in (VariableKind.SPLIT, VariableKind.SPLIT_HEX): 1491 acc = 0 1492 for cv in variable_cv_list(var): 1493 key = variable_store_key(var, cv) 1494 acc = (acc << 8) | (int(cv_values.get(key, 0)) & 0xFF) 1495 return acc 1496 if var.kind in ( 1497 VariableKind.DECIMAL, VariableKind.HEXADECIMAL, 1498 VariableKind.ENUM, VariableKind.UNKNOWN, VariableKind.SHORT_ADDRESS, 1499 ): 1500 key = variable_store_key(var, variable_primary_cv(var)) 1501 byte = int(cv_values.get(key, 0)) 1502 return var.bit_mask.extract(byte) 1503 raise UnsupportedVariableKind( 1504 f"variable_extract_from_store does not support {var.kind}" 1505 ) 1506 1507 1508def variable_extract_from_state( 1509 var: DecoderVariable, cv_bytes: dict[int, int], 1510) -> int: 1511 """Decode the variable's *human value* from a snapshot of CV bytes. 1512 1513 * DECIMAL / HEXADECIMAL — the masked byte value 1514 * ENUM — the wire value (caller can look up the choice label) 1515 * SHORT_ADDRESS — CV1 straight through 1516 * LONG_ADDRESS — ``(CV17 & 0x3F) << 8 | CV18`` (strip the two long- 1517 address marker bits set on CV17) 1518 * SPLIT / SPLIT_HEX — big-endian assembly across ``cv_list`` 1519 * CONSTANT — the pinned constant value 1520 * SPLIT_DATETIME, COMPOSITE, SPEED_TABLE — raise 1521 :class:`UnsupportedVariableKind`; those need step-specific work. 1522 """ 1523 if var.kind is VariableKind.CONSTANT: 1524 return var.constant_value or 0 1525 if var.kind is VariableKind.SHORT_ADDRESS: 1526 return cv_bytes.get(variable_primary_cv(var), 0) 1527 if var.kind is VariableKind.LONG_ADDRESS: 1528 high = cv_bytes.get(17, 0) & 0x3F 1529 low = cv_bytes.get(18, 0) & 0xFF 1530 return (high << 8) | low 1531 if var.kind in (VariableKind.SPLIT, VariableKind.SPLIT_HEX): 1532 acc = 0 1533 for cv in variable_cv_list(var): 1534 acc = (acc << 8) | (cv_bytes.get(cv, 0) & 0xFF) 1535 return acc 1536 if var.kind in ( 1537 VariableKind.DECIMAL, VariableKind.HEXADECIMAL, 1538 VariableKind.ENUM, VariableKind.UNKNOWN, 1539 ): 1540 byte = cv_bytes.get(variable_primary_cv(var), 0) 1541 return var.bit_mask.extract(byte) 1542 raise UnsupportedVariableKind( 1543 f"variable_extract_from_state does not support {var.kind}" 1544 ) 1545 1546 1547def variable_apply_to_state( 1548 var: DecoderVariable, human_value: int, cv_bytes: dict[int, int], 1549) -> dict[int, int]: 1550 """Compute the CV-byte writes needed to set the variable to *human_value*. 1551 1552 Returns ``{cv: new_byte_value}`` for every CV the write touches. 1553 Masked variables read the current byte out of *cv_bytes* to preserve 1554 the non-``V`` bits (read-modify-write); *cv_bytes* is the caller's 1555 running snapshot of committed values. 1556 """ 1557 if var.kind is VariableKind.CONSTANT: 1558 return {} # constants never write 1559 if var.read_only: 1560 return {} 1561 if var.kind is VariableKind.SHORT_ADDRESS: 1562 primary = variable_primary_cv(var) 1563 return {primary: human_value & 0x7F} 1564 if var.kind is VariableKind.LONG_ADDRESS: 1565 # NMRA: high byte carries the 0b11000000 long-address marker. 1566 addr = int(human_value) & 0x3FFF 1567 return {17: 0xC0 | ((addr >> 8) & 0x3F), 18: addr & 0xFF} 1568 if var.kind in (VariableKind.SPLIT, VariableKind.SPLIT_HEX): 1569 cvs = variable_cv_list(var) 1570 n = len(cvs) 1571 writes: dict[int, int] = {} 1572 v = int(human_value) 1573 # High-byte first, matching how ``extract`` reassembles. 1574 for idx, cv in enumerate(cvs): 1575 shift = 8 * (n - 1 - idx) 1576 writes[cv] = (v >> shift) & 0xFF 1577 return writes 1578 if var.kind in ( 1579 VariableKind.DECIMAL, VariableKind.HEXADECIMAL, 1580 VariableKind.ENUM, VariableKind.UNKNOWN, 1581 ): 1582 primary = variable_primary_cv(var) 1583 current = cv_bytes.get(primary, 0) 1584 return {primary: var.bit_mask.apply(current, int(human_value))} 1585 raise UnsupportedVariableKind( 1586 f"variable_apply_to_state does not support {var.kind}" 1587 ) 1588 1589 1590def resolve_composite_choice( 1591 var: DecoderVariable, choice_text: str, 1592) -> list[CompositeSetting]: 1593 """Return the ordered ``<compositeSetting>`` list for the named choice. 1594 1595 Composite variables fan out to sub-variable writes: picking a choice 1596 means writing each of its settings to the referenced sub-variables 1597 (looked up by ``item`` name). This helper does the choice → settings 1598 lookup; the caller (session/queue layer in step 4) is responsible 1599 for finding the referenced variables and applying their writes. 1600 1601 Raises ``KeyError`` if the choice text isn't a declared option. 1602 """ 1603 if var.kind is not VariableKind.COMPOSITE: 1604 raise UnsupportedVariableKind( 1605 f"resolve_composite_choice: variable is {var.kind}, not COMPOSITE" 1606 ) 1607 for cc in var.composite_choices: 1608 if cc.choice == choice_text: 1609 return list(cc.settings) 1610 if choice_text in cc.choice_by_lang.values(): 1611 return list(cc.settings) 1612 raise KeyError( 1613 f"no composite choice named {choice_text!r} on variable {var.item!r}" 1614 ) 1615 1616 1617def find_composite_current_choice( 1618 var: DecoderVariable, 1619 variables_by_item: dict[str, DecoderVariable], 1620 cv_bytes: dict[str, int], 1621) -> str | None: 1622 """Find the composite choice whose settings match the current state. 1623 1624 Composite variables don't have a wire representation of their own — 1625 they're syntactic sugar for a group of sub-variable writes. So the 1626 "current choice" is whichever named preset's ``<compositeSetting>`` 1627 values ALL match the corresponding sub-variables' current human 1628 values. If no choice matches (the sub-variables are in an 1629 intermediate state), returns ``None`` and the UI shows a blank / 1630 "custom" selection. 1631 1632 * ``variables_by_item`` — the decoder's full variable table keyed 1633 by item name. Composite settings reference sub-variables by 1634 item; this map lets us resolve them. 1635 * ``cv_bytes`` — string-keyed CV byte snapshot (same shape as 1636 :func:`variable_extract_from_store` consumes). 1637 """ 1638 if var.kind is not VariableKind.COMPOSITE: 1639 return None 1640 for cc in var.composite_choices: 1641 matched = True 1642 for setting in cc.settings: 1643 target = variables_by_item.get(setting.label) 1644 if target is None: 1645 matched = False 1646 break 1647 try: 1648 current = variable_extract_from_store(target, cv_bytes) 1649 except UnsupportedVariableKind: 1650 matched = False 1651 break 1652 if current != setting.value: 1653 matched = False 1654 break 1655 if matched and cc.settings: 1656 return cc.choice 1657 return None
36class DecoderOutput(BaseModel): 37 """Function-output mapping declared by a model.""" 38 name: str 39 label: str = ""
Function-output mapping declared by a model.
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.
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.
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
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.
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.
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 )
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
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.
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.
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.
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.
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.
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).
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.
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.
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).
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.
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.
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).
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".
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.
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.
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).
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.
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.
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.
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).
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.
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.
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.
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).