jmri_decoderpro.catalog

Decoder catalog — lightweight index + lazy full-definition loading.

Loading JMRI's 2000+ decoder XML files is too slow when done eagerly (parsing every variable, pane, and enum choice up front). Instead the catalog holds only a (family, model) → source_file metadata index built by iter_decoder_index(), and calls load_decoder() on demand the first time a specific definition is needed.

Trade-offs:

  • __iter__ yields DecoderIndexEntry metadata records — fast enumeration for enumerate/filter/list-models workflows.
  • find() returns a DecoderDefinition — triggers the lazy full-file parse for that entry (once; cached).
  • identify_by_cv() iterates metadata + calls find for each candidate. Rare user action, so the load-per-candidate is acceptable.

The from_files classmethod is retained for tests that supply already-parsed DecoderFile objects — those populate the cache immediately so no lazy load fires.

  1"""Decoder catalog — lightweight index + lazy full-definition loading.
  2
  3Loading JMRI's 2000+ decoder XML files is too slow when done eagerly
  4(parsing every variable, pane, and enum choice up front). Instead the
  5catalog holds only a ``(family, model) → source_file`` metadata index
  6built by :func:`iter_decoder_index`, and calls :func:`load_decoder` on
  7demand the first time a specific definition is needed.
  8
  9Trade-offs:
 10
 11* ``__iter__`` yields :class:`DecoderIndexEntry` metadata records —
 12  fast enumeration for enumerate/filter/list-models workflows.
 13* :meth:`find` returns a :class:`DecoderDefinition` — triggers the
 14  lazy full-file parse for that entry (once; cached).
 15* :meth:`identify_by_cv` iterates metadata + calls ``find`` for each
 16  candidate. Rare user action, so the load-per-candidate is acceptable.
 17
 18The ``from_files`` classmethod is retained for tests that supply
 19already-parsed :class:`DecoderFile` objects — those populate the cache
 20immediately so no lazy load fires.
 21"""
 22from __future__ import annotations
 23
 24import logging
 25import pathlib
 26from collections.abc import Iterator
 27from dataclasses import dataclass
 28
 29from lxml import etree
 30
 31from jmri_core.persistence.decoder_xml import (
 32    DecoderFamily,
 33    DecoderFile,
 34    DecoderIndexEntry,
 35    DecoderModel,
 36    DecoderPane,
 37    DecoderVariable,
 38    iter_decoder_index,
 39    load_decoder,
 40)
 41from jmri_core.persistence.programmer_xml import (
 42    ProgrammerConfig,
 43    iter_programmer_configs,
 44)
 45
 46_log = logging.getLogger(__name__)
 47
 48
 49@dataclass(frozen=True)
 50class DecoderKey:
 51    """Family + model identity, used as a stable lookup key."""
 52
 53    family: str
 54    model: str
 55
 56    def __str__(self) -> str:
 57        return f"{self.family} / {self.model}"
 58
 59
 60@dataclass(frozen=True)
 61class DecoderDefinition:
 62    """One decoder model's fully parsed definition.
 63
 64    Constructed on demand by :meth:`DecoderCatalog.find` from the source
 65    XML file recorded in the catalog's metadata index. ``panes`` carries
 66    the decoder-specific tabs; the standard tabs are layered on top at
 67    UI-render time.
 68    """
 69
 70    family: DecoderFamily
 71    model: DecoderModel
 72    variables: tuple[DecoderVariable, ...]
 73    panes: tuple[DecoderPane, ...] = ()
 74    source_file: pathlib.Path | None = None
 75
 76    @property
 77    def key(self) -> DecoderKey:
 78        return DecoderKey(family=self.family.name, model=self.model.model)
 79
 80    @property
 81    def mfg(self) -> str:
 82        return self.family.mfg
 83
 84
 85class DecoderCatalog:
 86    """Fast metadata index + lazy full-definition loader.
 87
 88    Two internal maps:
 89
 90    * ``_metadata`` — ``DecoderKey → DecoderIndexEntry`` from the fast scan
 91    * ``_cache``    — ``DecoderKey → DecoderDefinition`` filled on demand
 92
 93    ``programmers`` holds the standard programmer definitions
 94    (``Comprehensive.xml``, ``Basic.xml``, ...) parsed from the sibling
 95    ``programmers/`` dir. Those are small and load eagerly.
 96    """
 97
 98    def __init__(self) -> None:
 99        self._metadata: dict[DecoderKey, DecoderIndexEntry] = {}
100        self._cache: dict[DecoderKey, DecoderDefinition] = {}
101        self.programmers: dict[str, ProgrammerConfig] = {}
102        # NMRA CV8 manufacturer ID → mfg name, loaded from
103        # ``decoderIndex.xml``'s ``<mfgList>`` at :meth:`from_directory`.
104        # Falls back to :data:`_NMRA_MFG_IDS` when the catalog wasn't
105        # loaded from a JMRI xml tree (tests, standalone probes).
106        self.mfg_ids: dict[int, str] = {}
107
108    # ----- construction ---------------------------------------------------
109
110    @classmethod
111    def from_directory(cls, root: str | pathlib.Path) -> DecoderCatalog:
112        """Build the catalog by scanning *root* for decoder XML files.
113
114        Only the identity fields are extracted at this stage — variables
115        + panes are parsed lazily via :meth:`find`. The sibling
116        ``programmers/`` directory (if present) is loaded eagerly since
117        it's small.
118        """
119        catalog = cls()
120        for entry in iter_decoder_index(root):
121            catalog._metadata[DecoderKey(entry.family, entry.model)] = entry
122        # decoderIndex.xml sits at the ``xml/`` root next to the
123        # ``decoders/`` dir and carries the authoritative NMRA
124        # manufacturer table (currently 179 entries). Load it if
125        # present so identify can map any CV8 the decoder returns.
126        index_path = pathlib.Path(root).parent / "decoderIndex.xml"
127        if index_path.is_file():
128            catalog.mfg_ids = _load_mfg_ids(index_path)
129        programmers_dir = pathlib.Path(root).parent / "programmers"
130        if programmers_dir.is_dir():
131            catalog.programmers = {
132                pc.name: pc for pc in iter_programmer_configs(programmers_dir)
133            }
134            _log.info(
135                "DecoderCatalog: indexed %d entries + %d programmer configs",
136                len(catalog), len(catalog.programmers),
137            )
138        else:
139            _log.info("DecoderCatalog: indexed %d entries", len(catalog))
140        return catalog
141
142    @classmethod
143    def from_files(cls, files: list[DecoderFile]) -> DecoderCatalog:
144        """Build the catalog from already-parsed :class:`DecoderFile`
145        objects. Used by tests + callers that want eager loading.
146
147        Populates both the metadata index and the definition cache so
148        subsequent :meth:`find` calls don't trigger any file I/O.
149        """
150        catalog = cls()
151        for f in files:
152            catalog._absorb(f)
153        return catalog
154
155    def add_index_entry(self, entry: DecoderIndexEntry) -> None:
156        """Register a scanned :class:`DecoderIndexEntry` in the metadata
157        index. Full-definition parse is deferred to first :meth:`find`.
158
159        Used by the web catalog-load worker so it can drive the
160        thermometer at scan time rather than parse time.
161        """
162        self._metadata[DecoderKey(entry.family, entry.model)] = entry
163
164    def _absorb(self, df: DecoderFile) -> None:
165        """Populate both metadata and cache from an already-parsed file."""
166        if df.family is None:
167            return
168        for model in df.family.models:
169            key = DecoderKey(df.family.name, model.model)
170            self._metadata[key] = DecoderIndexEntry(
171                family=df.family.name,
172                mfg=df.family.mfg,
173                model=model.model,
174                source_file=df.path or pathlib.Path(""),
175                # Propagate the ``<model productID="…"/>`` attribute
176                # so identify_by_cv_extended can narrow past the
177                # family level. Missing on tests / older files stays
178                # empty string, which the extended-identify path
179                # treats as "no productID match, keep in fallback".
180                product_id=getattr(model, "product_id", ""),
181            )
182            self._cache[key] = DecoderDefinition(
183                family=df.family,
184                model=model,
185                variables=tuple(df.variables),
186                panes=tuple(df.panes),
187                source_file=df.path,
188            )
189
190    # ----- lookup ---------------------------------------------------------
191
192    def __len__(self) -> int:
193        return len(self._metadata)
194
195    def __iter__(self) -> Iterator[DecoderIndexEntry]:
196        """Iterate metadata entries — fast, no full parse.
197
198        Callers that need variables/panes should use :meth:`find` on the
199        entry's key.
200        """
201        return iter(self._metadata.values())
202
203    def get(self, key: DecoderKey) -> DecoderDefinition | None:
204        return self._lazy_load(key)
205
206    def find(self, family: str, model: str) -> DecoderDefinition | None:
207        return self._lazy_load(DecoderKey(family=family, model=model))
208
209    def _lazy_load(self, key: DecoderKey) -> DecoderDefinition | None:
210        """Return the cached definition, parsing the source file on first hit."""
211        if key not in self._metadata:
212            return None
213        cached = self._cache.get(key)
214        if cached is not None:
215            return cached
216        meta = self._metadata[key]
217        source = meta.source_file
218        try:
219            df = load_decoder(source)
220        except Exception as e:  # noqa: BLE001
221            _log.warning(
222                "DecoderCatalog: lazy-load %s (%s/%s) failed: %s",
223                source, key.family, key.model, e,
224            )
225            return None
226        # One decoder file typically declares many models — cache them
227        # all, so a load triggered by find(F, M1) also serves find(F, M2)
228        # without re-parsing the file.
229        self._absorb(df)
230        return self._cache.get(key)
231
232    # ----- fast metadata queries -----------------------------------------
233
234    def manufacturers(self) -> list[str]:
235        return sorted({e.mfg for e in self._metadata.values() if e.mfg})
236
237    def families(self, mfg: str | None = None) -> list[str]:
238        return sorted({
239            e.family for e in self._metadata.values()
240            if e.family and (mfg is None or e.mfg == mfg)
241        })
242
243    def models(self, family: str) -> list[str]:
244        return sorted({
245            e.model for e in self._metadata.values() if e.family == family
246        })
247
248    # ----- identification -------------------------------------------------
249
250    def identify_by_cv(
251        self, cv7_version: int, cv8_mfg: int,
252    ) -> list[DecoderIndexEntry]:
253        """Return metadata entries consistent with CV7/CV8 values.
254
255        Metadata-only (no lazy load): iterating a full catalog and
256        parsing every candidate file is prohibitively slow on modest
257        hardware — the JMRI decoder tree has ~2000 files, each with
258        xi:include fragments to resolve. Instead we filter by mfg alone
259        (from :attr:`mfg_ids` when the catalog was loaded from a JMRI
260        xml tree, or the small stopgap :data:`_NMRA_MFG_IDS` when it
261        wasn't) and let the caller pick a specific entry to fully load
262        via :meth:`find`.
263
264        Raises :class:`UnknownDecoderIdentityError` when *cv8_mfg* isn't
265        a known NMRA manufacturer ID — that includes the NCE NoAck
266        sentinel byte (``0x30`` = 48) that a failed programming-track
267        read returns. Surfacing the error is much friendlier than
268        silently returning "no matches" or, worse, iterating the whole
269        catalog when the read failed.
270        """
271        mfg_name = self.mfg_ids.get(cv8_mfg) or _NMRA_MFG_IDS.get(cv8_mfg)
272        if mfg_name is None:
273            raise UnknownDecoderIdentityError(cv7_version, cv8_mfg)
274        # Match case-insensitively AND on the leading token so
275        # "SoundTraxx (Throttle-Up)" matches entries stored as either
276        # form — decoderIndex.xml uses the parenthesised name; some
277        # older decoder files use the short form.
278        needle = mfg_name.split(" (")[0].lower()
279        # Filter by CV7 within the model's declared version range.
280        # JMRI decoder XMLs use ``lowVersionID`` / ``highVersionID`` on
281        # ``<family>`` (and less often on ``<model>``) to gate which
282        # decoder revisions a definition applies to. Models with no
283        # declared range stay wildcard (0..255) so old files without
284        # the attributes aren't hidden from the identify list.
285        return [
286            entry for entry in self._metadata.values()
287            if needle in entry.mfg.lower()
288            and entry.low_version_id <= cv7_version <= entry.high_version_id
289        ]
290
291    def identify_by_cv_extended(
292        self, cv7_version: int, cv8_mfg: int, product_id: int | None,
293    ) -> list[DecoderIndexEntry]:
294        """Same as :meth:`identify_by_cv`, then narrow by *product_id*.
295
296        When *product_id* is ``None`` (no probe run, or the probe
297        determined the model doesn't populate the productID CVs),
298        this returns the same list as :meth:`identify_by_cv` —
299        the caller shows the family-level picker and the user
300        chooses the model.
301
302        When *product_id* is an int, entries whose
303        :attr:`~jmri_core.persistence.decoder_xml.DecoderIndexEntry.product_id`
304        (the ``<model productID="…"/>`` XML attribute) matches are
305        preferred. JMRI stores comma-separated productID lists on
306        one model when the same XML applies to several variants —
307        we split and match against each. Entries without a
308        productID attribute are always kept in the fallback list
309        so no CV7/CV8 match is ever lost.
310
311        If any entry has a productID match, only those matches are
312        returned. If none do, we fall back to the CV7/CV8-only list
313        — this handles cases like a catalog scan that missed newer
314        productIDs the operator's decoder actually reports.
315        """
316        base = self.identify_by_cv(cv7_version, cv8_mfg)
317        if product_id is None:
318            return base
319        product_id_str = str(product_id)
320        matches = [
321            entry for entry in base
322            if _model_matches_product_id(entry.product_id, product_id_str)
323        ]
324        return matches if matches else base
325
326
327def _model_matches_product_id(entry_field: str, needle: str) -> bool:
328    """Return True when *needle* matches any productID token in
329    *entry_field*. JMRI decoder XML stores multi-productID models as
330    a comma-separated list (``"12345,12346"``); a single-token match
331    is enough."""
332    if not entry_field:
333        return False
334    return any(
335        token.strip() == needle for token in entry_field.split(",")
336    )
337
338
339class UnknownDecoderIdentityError(Exception):
340    """CV7/CV8 read back values we can't map to a known decoder.
341
342    Distinct from "we know the mfg but have no matching decoder in the
343    catalog" (which returns an empty list). This is raised when we
344    can't even interpret the identity bytes — most often because the
345    read failed on the wire and the CS returned its NoAck sentinel.
346    """
347
348    # CV8 values that almost always mean "the physical read failed"
349    # rather than a real (but unrecognised) mfg ID:
350    #   0   — NMRA reserved / decoder didn't respond
351    #   48  — NCE PowerPro NoAck sentinel byte (0x30)
352    #   255 — 0xFF, common "read timed out" fill
353    _WIRE_FAIL_SENTINELS = {0, 48, 255}
354
355    def __init__(self, cv7: int, cv8: int) -> None:
356        if cv8 in self._WIRE_FAIL_SENTINELS:
357            hint = (
358                f"CV8={cv8} looks like a failed wire read (0/48/255 are the "
359                "common no-decoder / no-ack sentinels). Retry, or verify the "
360                "decoder is on the programming track and powered."
361            )
362        else:
363            hint = (
364                f"CV8={cv8} is not a known NMRA manufacturer ID. The catalog "
365                "may not be loaded (Load catalog above), or this is a very "
366                "new / obscure decoder."
367            )
368        super().__init__(f"CV7={cv7} CV8={cv8} — {hint}")
369        self.cv7 = cv7
370        self.cv8 = cv8
371
372
373def _matches_cv7(defn: DecoderDefinition, target: int) -> bool:
374    """Return True if the decoder's CV7 'Decoder Version' default == *target*.
375
376    Many decoder definitions don't pin CV7 (they declare it as a
377    read-only identity variable); in those cases we accept the match
378    rather than excluding the candidate. The CV8 manufacturer match is
379    the more reliable signal.
380    """
381    for variable in defn.variables:
382        if variable.cv != "7":
383            continue
384        if variable.default is None:
385            return True
386        return variable.default == target
387    return True   # CV7 unspecified — leave open
388
389
390# Top-N NMRA manufacturer IDs from the public registry. Used only as a
391# best-effort filter; missing IDs just fall through to the unfiltered
392# (CV7-only) match. When the catalog is loaded from a JMRI xml tree
393# via :meth:`DecoderCatalog.from_directory`, the authoritative table
394# (179 manufacturers) is loaded from ``decoderIndex.xml`` and used in
395# preference to this stopgap. IDs and names below match the JMRI
396# ``<mfgList>`` — do not "correct" without cross-checking that file.
397_NMRA_MFG_IDS: dict[int, str] = {
398    1:   "CML Systems",
399    11:  "North Coast Engineering",
400    54:  "TCH Technology",
401    97:  "Doehler und Haass",
402    99:  "Lenz",
403    101: "Bachmann Trains",
404    113: "QSIndustries",
405    127: "Atlas",
406    129: "Digitrax",
407    131: "Trix Modelleisenbahn",
408    141: "SoundTraxx (Throttle-Up)",
409    143: "MRC",
410    145: "Zimo",
411    151: "Electronic Solutions Ulm GmbH",
412    153: "Train Control Systems",
413    161: "Roco Modelspielwaren",
414    165: "MERG",
415}
416
417
418def _load_mfg_ids(index_path: pathlib.Path) -> dict[int, str]:
419    """Parse the ``<mfgList>`` block of ``decoderIndex.xml``.
420
421    Returns ``{mfgID → mfg name}`` for every ``<manufacturer>`` entry.
422    Malformed IDs are skipped rather than raised — JMRI's file has
423    been solid for years, but keeping the loader tolerant means a
424    single bad entry can't blank the whole identify feature.
425    """
426    try:
427        root = etree.parse(str(index_path)).getroot()
428    except (etree.XMLSyntaxError, OSError) as e:
429        _log.warning("failed to load %s: %s", index_path, e)
430        return {}
431    ids: dict[int, str] = {}
432    for entry in root.iter("manufacturer"):
433        raw_id = entry.get("mfgID", "")
434        name = entry.get("mfg", "")
435        if not name:
436            continue
437        try:
438            ids[int(raw_id)] = name
439        except ValueError:
440            continue
441    _log.info(
442        "DecoderCatalog: loaded %d NMRA manufacturer entries from %s",
443        len(ids), index_path.name,
444    )
445    return ids
@dataclass(frozen=True)
class DecoderKey:
50@dataclass(frozen=True)
51class DecoderKey:
52    """Family + model identity, used as a stable lookup key."""
53
54    family: str
55    model: str
56
57    def __str__(self) -> str:
58        return f"{self.family} / {self.model}"

Family + model identity, used as a stable lookup key.

DecoderKey(family: str, model: str)
family: str
model: str
@dataclass(frozen=True)
class DecoderDefinition:
61@dataclass(frozen=True)
62class DecoderDefinition:
63    """One decoder model's fully parsed definition.
64
65    Constructed on demand by :meth:`DecoderCatalog.find` from the source
66    XML file recorded in the catalog's metadata index. ``panes`` carries
67    the decoder-specific tabs; the standard tabs are layered on top at
68    UI-render time.
69    """
70
71    family: DecoderFamily
72    model: DecoderModel
73    variables: tuple[DecoderVariable, ...]
74    panes: tuple[DecoderPane, ...] = ()
75    source_file: pathlib.Path | None = None
76
77    @property
78    def key(self) -> DecoderKey:
79        return DecoderKey(family=self.family.name, model=self.model.model)
80
81    @property
82    def mfg(self) -> str:
83        return self.family.mfg

One decoder model's fully parsed definition.

Constructed on demand by DecoderCatalog.find() from the source XML file recorded in the catalog's metadata index. panes carries the decoder-specific tabs; the standard tabs are layered on top at UI-render time.

DecoderDefinition( family: jmri_core.persistence.DecoderFamily, model: jmri_core.persistence.DecoderModel, variables: tuple[jmri_core.persistence.DecoderVariable, ...], panes: tuple[jmri_core.persistence.DecoderPane, ...] = (), source_file: pathlib._local.Path | None = None)
variables: tuple[jmri_core.persistence.DecoderVariable, ...]
panes: tuple[jmri_core.persistence.DecoderPane, ...] = ()
source_file: pathlib._local.Path | None = None
key: DecoderKey
77    @property
78    def key(self) -> DecoderKey:
79        return DecoderKey(family=self.family.name, model=self.model.model)
mfg: str
81    @property
82    def mfg(self) -> str:
83        return self.family.mfg
class DecoderCatalog:
 86class DecoderCatalog:
 87    """Fast metadata index + lazy full-definition loader.
 88
 89    Two internal maps:
 90
 91    * ``_metadata`` — ``DecoderKey → DecoderIndexEntry`` from the fast scan
 92    * ``_cache``    — ``DecoderKey → DecoderDefinition`` filled on demand
 93
 94    ``programmers`` holds the standard programmer definitions
 95    (``Comprehensive.xml``, ``Basic.xml``, ...) parsed from the sibling
 96    ``programmers/`` dir. Those are small and load eagerly.
 97    """
 98
 99    def __init__(self) -> None:
100        self._metadata: dict[DecoderKey, DecoderIndexEntry] = {}
101        self._cache: dict[DecoderKey, DecoderDefinition] = {}
102        self.programmers: dict[str, ProgrammerConfig] = {}
103        # NMRA CV8 manufacturer ID → mfg name, loaded from
104        # ``decoderIndex.xml``'s ``<mfgList>`` at :meth:`from_directory`.
105        # Falls back to :data:`_NMRA_MFG_IDS` when the catalog wasn't
106        # loaded from a JMRI xml tree (tests, standalone probes).
107        self.mfg_ids: dict[int, str] = {}
108
109    # ----- construction ---------------------------------------------------
110
111    @classmethod
112    def from_directory(cls, root: str | pathlib.Path) -> DecoderCatalog:
113        """Build the catalog by scanning *root* for decoder XML files.
114
115        Only the identity fields are extracted at this stage — variables
116        + panes are parsed lazily via :meth:`find`. The sibling
117        ``programmers/`` directory (if present) is loaded eagerly since
118        it's small.
119        """
120        catalog = cls()
121        for entry in iter_decoder_index(root):
122            catalog._metadata[DecoderKey(entry.family, entry.model)] = entry
123        # decoderIndex.xml sits at the ``xml/`` root next to the
124        # ``decoders/`` dir and carries the authoritative NMRA
125        # manufacturer table (currently 179 entries). Load it if
126        # present so identify can map any CV8 the decoder returns.
127        index_path = pathlib.Path(root).parent / "decoderIndex.xml"
128        if index_path.is_file():
129            catalog.mfg_ids = _load_mfg_ids(index_path)
130        programmers_dir = pathlib.Path(root).parent / "programmers"
131        if programmers_dir.is_dir():
132            catalog.programmers = {
133                pc.name: pc for pc in iter_programmer_configs(programmers_dir)
134            }
135            _log.info(
136                "DecoderCatalog: indexed %d entries + %d programmer configs",
137                len(catalog), len(catalog.programmers),
138            )
139        else:
140            _log.info("DecoderCatalog: indexed %d entries", len(catalog))
141        return catalog
142
143    @classmethod
144    def from_files(cls, files: list[DecoderFile]) -> DecoderCatalog:
145        """Build the catalog from already-parsed :class:`DecoderFile`
146        objects. Used by tests + callers that want eager loading.
147
148        Populates both the metadata index and the definition cache so
149        subsequent :meth:`find` calls don't trigger any file I/O.
150        """
151        catalog = cls()
152        for f in files:
153            catalog._absorb(f)
154        return catalog
155
156    def add_index_entry(self, entry: DecoderIndexEntry) -> None:
157        """Register a scanned :class:`DecoderIndexEntry` in the metadata
158        index. Full-definition parse is deferred to first :meth:`find`.
159
160        Used by the web catalog-load worker so it can drive the
161        thermometer at scan time rather than parse time.
162        """
163        self._metadata[DecoderKey(entry.family, entry.model)] = entry
164
165    def _absorb(self, df: DecoderFile) -> None:
166        """Populate both metadata and cache from an already-parsed file."""
167        if df.family is None:
168            return
169        for model in df.family.models:
170            key = DecoderKey(df.family.name, model.model)
171            self._metadata[key] = DecoderIndexEntry(
172                family=df.family.name,
173                mfg=df.family.mfg,
174                model=model.model,
175                source_file=df.path or pathlib.Path(""),
176                # Propagate the ``<model productID="…"/>`` attribute
177                # so identify_by_cv_extended can narrow past the
178                # family level. Missing on tests / older files stays
179                # empty string, which the extended-identify path
180                # treats as "no productID match, keep in fallback".
181                product_id=getattr(model, "product_id", ""),
182            )
183            self._cache[key] = DecoderDefinition(
184                family=df.family,
185                model=model,
186                variables=tuple(df.variables),
187                panes=tuple(df.panes),
188                source_file=df.path,
189            )
190
191    # ----- lookup ---------------------------------------------------------
192
193    def __len__(self) -> int:
194        return len(self._metadata)
195
196    def __iter__(self) -> Iterator[DecoderIndexEntry]:
197        """Iterate metadata entries — fast, no full parse.
198
199        Callers that need variables/panes should use :meth:`find` on the
200        entry's key.
201        """
202        return iter(self._metadata.values())
203
204    def get(self, key: DecoderKey) -> DecoderDefinition | None:
205        return self._lazy_load(key)
206
207    def find(self, family: str, model: str) -> DecoderDefinition | None:
208        return self._lazy_load(DecoderKey(family=family, model=model))
209
210    def _lazy_load(self, key: DecoderKey) -> DecoderDefinition | None:
211        """Return the cached definition, parsing the source file on first hit."""
212        if key not in self._metadata:
213            return None
214        cached = self._cache.get(key)
215        if cached is not None:
216            return cached
217        meta = self._metadata[key]
218        source = meta.source_file
219        try:
220            df = load_decoder(source)
221        except Exception as e:  # noqa: BLE001
222            _log.warning(
223                "DecoderCatalog: lazy-load %s (%s/%s) failed: %s",
224                source, key.family, key.model, e,
225            )
226            return None
227        # One decoder file typically declares many models — cache them
228        # all, so a load triggered by find(F, M1) also serves find(F, M2)
229        # without re-parsing the file.
230        self._absorb(df)
231        return self._cache.get(key)
232
233    # ----- fast metadata queries -----------------------------------------
234
235    def manufacturers(self) -> list[str]:
236        return sorted({e.mfg for e in self._metadata.values() if e.mfg})
237
238    def families(self, mfg: str | None = None) -> list[str]:
239        return sorted({
240            e.family for e in self._metadata.values()
241            if e.family and (mfg is None or e.mfg == mfg)
242        })
243
244    def models(self, family: str) -> list[str]:
245        return sorted({
246            e.model for e in self._metadata.values() if e.family == family
247        })
248
249    # ----- identification -------------------------------------------------
250
251    def identify_by_cv(
252        self, cv7_version: int, cv8_mfg: int,
253    ) -> list[DecoderIndexEntry]:
254        """Return metadata entries consistent with CV7/CV8 values.
255
256        Metadata-only (no lazy load): iterating a full catalog and
257        parsing every candidate file is prohibitively slow on modest
258        hardware — the JMRI decoder tree has ~2000 files, each with
259        xi:include fragments to resolve. Instead we filter by mfg alone
260        (from :attr:`mfg_ids` when the catalog was loaded from a JMRI
261        xml tree, or the small stopgap :data:`_NMRA_MFG_IDS` when it
262        wasn't) and let the caller pick a specific entry to fully load
263        via :meth:`find`.
264
265        Raises :class:`UnknownDecoderIdentityError` when *cv8_mfg* isn't
266        a known NMRA manufacturer ID — that includes the NCE NoAck
267        sentinel byte (``0x30`` = 48) that a failed programming-track
268        read returns. Surfacing the error is much friendlier than
269        silently returning "no matches" or, worse, iterating the whole
270        catalog when the read failed.
271        """
272        mfg_name = self.mfg_ids.get(cv8_mfg) or _NMRA_MFG_IDS.get(cv8_mfg)
273        if mfg_name is None:
274            raise UnknownDecoderIdentityError(cv7_version, cv8_mfg)
275        # Match case-insensitively AND on the leading token so
276        # "SoundTraxx (Throttle-Up)" matches entries stored as either
277        # form — decoderIndex.xml uses the parenthesised name; some
278        # older decoder files use the short form.
279        needle = mfg_name.split(" (")[0].lower()
280        # Filter by CV7 within the model's declared version range.
281        # JMRI decoder XMLs use ``lowVersionID`` / ``highVersionID`` on
282        # ``<family>`` (and less often on ``<model>``) to gate which
283        # decoder revisions a definition applies to. Models with no
284        # declared range stay wildcard (0..255) so old files without
285        # the attributes aren't hidden from the identify list.
286        return [
287            entry for entry in self._metadata.values()
288            if needle in entry.mfg.lower()
289            and entry.low_version_id <= cv7_version <= entry.high_version_id
290        ]
291
292    def identify_by_cv_extended(
293        self, cv7_version: int, cv8_mfg: int, product_id: int | None,
294    ) -> list[DecoderIndexEntry]:
295        """Same as :meth:`identify_by_cv`, then narrow by *product_id*.
296
297        When *product_id* is ``None`` (no probe run, or the probe
298        determined the model doesn't populate the productID CVs),
299        this returns the same list as :meth:`identify_by_cv` —
300        the caller shows the family-level picker and the user
301        chooses the model.
302
303        When *product_id* is an int, entries whose
304        :attr:`~jmri_core.persistence.decoder_xml.DecoderIndexEntry.product_id`
305        (the ``<model productID="…"/>`` XML attribute) matches are
306        preferred. JMRI stores comma-separated productID lists on
307        one model when the same XML applies to several variants —
308        we split and match against each. Entries without a
309        productID attribute are always kept in the fallback list
310        so no CV7/CV8 match is ever lost.
311
312        If any entry has a productID match, only those matches are
313        returned. If none do, we fall back to the CV7/CV8-only list
314        — this handles cases like a catalog scan that missed newer
315        productIDs the operator's decoder actually reports.
316        """
317        base = self.identify_by_cv(cv7_version, cv8_mfg)
318        if product_id is None:
319            return base
320        product_id_str = str(product_id)
321        matches = [
322            entry for entry in base
323            if _model_matches_product_id(entry.product_id, product_id_str)
324        ]
325        return matches if matches else base

Fast metadata index + lazy full-definition loader.

Two internal maps:

  • _metadata — DecoderKey → DecoderIndexEntry from the fast scan
  • _cache — DecoderKey → DecoderDefinition filled on demand

programmers holds the standard programmer definitions (Comprehensive.xml, Basic.xml, ...) parsed from the sibling programmers/ dir. Those are small and load eagerly.

programmers: dict[str, jmri_core.persistence.ProgrammerConfig]
mfg_ids: dict[int, str]
@classmethod
def from_directory( cls, root: str | pathlib._local.Path) -> DecoderCatalog:
111    @classmethod
112    def from_directory(cls, root: str | pathlib.Path) -> DecoderCatalog:
113        """Build the catalog by scanning *root* for decoder XML files.
114
115        Only the identity fields are extracted at this stage — variables
116        + panes are parsed lazily via :meth:`find`. The sibling
117        ``programmers/`` directory (if present) is loaded eagerly since
118        it's small.
119        """
120        catalog = cls()
121        for entry in iter_decoder_index(root):
122            catalog._metadata[DecoderKey(entry.family, entry.model)] = entry
123        # decoderIndex.xml sits at the ``xml/`` root next to the
124        # ``decoders/`` dir and carries the authoritative NMRA
125        # manufacturer table (currently 179 entries). Load it if
126        # present so identify can map any CV8 the decoder returns.
127        index_path = pathlib.Path(root).parent / "decoderIndex.xml"
128        if index_path.is_file():
129            catalog.mfg_ids = _load_mfg_ids(index_path)
130        programmers_dir = pathlib.Path(root).parent / "programmers"
131        if programmers_dir.is_dir():
132            catalog.programmers = {
133                pc.name: pc for pc in iter_programmer_configs(programmers_dir)
134            }
135            _log.info(
136                "DecoderCatalog: indexed %d entries + %d programmer configs",
137                len(catalog), len(catalog.programmers),
138            )
139        else:
140            _log.info("DecoderCatalog: indexed %d entries", len(catalog))
141        return catalog

Build the catalog by scanning root for decoder XML files.

Only the identity fields are extracted at this stage — variables

  • panes are parsed lazily via find(). The sibling programmers/ directory (if present) is loaded eagerly since it's small.
@classmethod
def from_files( cls, files: list[jmri_core.persistence.DecoderFile]) -> DecoderCatalog:
143    @classmethod
144    def from_files(cls, files: list[DecoderFile]) -> DecoderCatalog:
145        """Build the catalog from already-parsed :class:`DecoderFile`
146        objects. Used by tests + callers that want eager loading.
147
148        Populates both the metadata index and the definition cache so
149        subsequent :meth:`find` calls don't trigger any file I/O.
150        """
151        catalog = cls()
152        for f in files:
153            catalog._absorb(f)
154        return catalog

Build the catalog from already-parsed DecoderFile objects. Used by tests + callers that want eager loading.

Populates both the metadata index and the definition cache so subsequent find() calls don't trigger any file I/O.

def add_index_entry(self, entry: jmri_core.persistence.DecoderIndexEntry) -> None:
156    def add_index_entry(self, entry: DecoderIndexEntry) -> None:
157        """Register a scanned :class:`DecoderIndexEntry` in the metadata
158        index. Full-definition parse is deferred to first :meth:`find`.
159
160        Used by the web catalog-load worker so it can drive the
161        thermometer at scan time rather than parse time.
162        """
163        self._metadata[DecoderKey(entry.family, entry.model)] = entry

Register a scanned DecoderIndexEntry in the metadata index. Full-definition parse is deferred to first find().

Used by the web catalog-load worker so it can drive the thermometer at scan time rather than parse time.

def get( self, key: DecoderKey) -> DecoderDefinition | None:
204    def get(self, key: DecoderKey) -> DecoderDefinition | None:
205        return self._lazy_load(key)
def find( self, family: str, model: str) -> DecoderDefinition | None:
207    def find(self, family: str, model: str) -> DecoderDefinition | None:
208        return self._lazy_load(DecoderKey(family=family, model=model))
def manufacturers(self) -> list[str]:
235    def manufacturers(self) -> list[str]:
236        return sorted({e.mfg for e in self._metadata.values() if e.mfg})
def families(self, mfg: str | None = None) -> list[str]:
238    def families(self, mfg: str | None = None) -> list[str]:
239        return sorted({
240            e.family for e in self._metadata.values()
241            if e.family and (mfg is None or e.mfg == mfg)
242        })
def models(self, family: str) -> list[str]:
244    def models(self, family: str) -> list[str]:
245        return sorted({
246            e.model for e in self._metadata.values() if e.family == family
247        })
def identify_by_cv( self, cv7_version: int, cv8_mfg: int) -> list[jmri_core.persistence.DecoderIndexEntry]:
251    def identify_by_cv(
252        self, cv7_version: int, cv8_mfg: int,
253    ) -> list[DecoderIndexEntry]:
254        """Return metadata entries consistent with CV7/CV8 values.
255
256        Metadata-only (no lazy load): iterating a full catalog and
257        parsing every candidate file is prohibitively slow on modest
258        hardware — the JMRI decoder tree has ~2000 files, each with
259        xi:include fragments to resolve. Instead we filter by mfg alone
260        (from :attr:`mfg_ids` when the catalog was loaded from a JMRI
261        xml tree, or the small stopgap :data:`_NMRA_MFG_IDS` when it
262        wasn't) and let the caller pick a specific entry to fully load
263        via :meth:`find`.
264
265        Raises :class:`UnknownDecoderIdentityError` when *cv8_mfg* isn't
266        a known NMRA manufacturer ID — that includes the NCE NoAck
267        sentinel byte (``0x30`` = 48) that a failed programming-track
268        read returns. Surfacing the error is much friendlier than
269        silently returning "no matches" or, worse, iterating the whole
270        catalog when the read failed.
271        """
272        mfg_name = self.mfg_ids.get(cv8_mfg) or _NMRA_MFG_IDS.get(cv8_mfg)
273        if mfg_name is None:
274            raise UnknownDecoderIdentityError(cv7_version, cv8_mfg)
275        # Match case-insensitively AND on the leading token so
276        # "SoundTraxx (Throttle-Up)" matches entries stored as either
277        # form — decoderIndex.xml uses the parenthesised name; some
278        # older decoder files use the short form.
279        needle = mfg_name.split(" (")[0].lower()
280        # Filter by CV7 within the model's declared version range.
281        # JMRI decoder XMLs use ``lowVersionID`` / ``highVersionID`` on
282        # ``<family>`` (and less often on ``<model>``) to gate which
283        # decoder revisions a definition applies to. Models with no
284        # declared range stay wildcard (0..255) so old files without
285        # the attributes aren't hidden from the identify list.
286        return [
287            entry for entry in self._metadata.values()
288            if needle in entry.mfg.lower()
289            and entry.low_version_id <= cv7_version <= entry.high_version_id
290        ]

Return metadata entries consistent with CV7/CV8 values.

Metadata-only (no lazy load): iterating a full catalog and parsing every candidate file is prohibitively slow on modest hardware — the JMRI decoder tree has ~2000 files, each with xi:include fragments to resolve. Instead we filter by mfg alone (from mfg_ids when the catalog was loaded from a JMRI xml tree, or the small stopgap _NMRA_MFG_IDS when it wasn't) and let the caller pick a specific entry to fully load via find().

Raises UnknownDecoderIdentityError when cv8_mfg isn't a known NMRA manufacturer ID — that includes the NCE NoAck sentinel byte (0x30 = 48) that a failed programming-track read returns. Surfacing the error is much friendlier than silently returning "no matches" or, worse, iterating the whole catalog when the read failed.

def identify_by_cv_extended( self, cv7_version: int, cv8_mfg: int, product_id: int | None) -> list[jmri_core.persistence.DecoderIndexEntry]:
292    def identify_by_cv_extended(
293        self, cv7_version: int, cv8_mfg: int, product_id: int | None,
294    ) -> list[DecoderIndexEntry]:
295        """Same as :meth:`identify_by_cv`, then narrow by *product_id*.
296
297        When *product_id* is ``None`` (no probe run, or the probe
298        determined the model doesn't populate the productID CVs),
299        this returns the same list as :meth:`identify_by_cv` —
300        the caller shows the family-level picker and the user
301        chooses the model.
302
303        When *product_id* is an int, entries whose
304        :attr:`~jmri_core.persistence.decoder_xml.DecoderIndexEntry.product_id`
305        (the ``<model productID="…"/>`` XML attribute) matches are
306        preferred. JMRI stores comma-separated productID lists on
307        one model when the same XML applies to several variants —
308        we split and match against each. Entries without a
309        productID attribute are always kept in the fallback list
310        so no CV7/CV8 match is ever lost.
311
312        If any entry has a productID match, only those matches are
313        returned. If none do, we fall back to the CV7/CV8-only list
314        — this handles cases like a catalog scan that missed newer
315        productIDs the operator's decoder actually reports.
316        """
317        base = self.identify_by_cv(cv7_version, cv8_mfg)
318        if product_id is None:
319            return base
320        product_id_str = str(product_id)
321        matches = [
322            entry for entry in base
323            if _model_matches_product_id(entry.product_id, product_id_str)
324        ]
325        return matches if matches else base

Same as identify_by_cv(), then narrow by product_id.

When product_id is None (no probe run, or the probe determined the model doesn't populate the productID CVs), this returns the same list as identify_by_cv() — the caller shows the family-level picker and the user chooses the model.

When product_id is an int, entries whose ~jmri_core.persistence.decoder_xml.DecoderIndexEntry.product_id (the <model productID="…"/> XML attribute) matches are preferred. JMRI stores comma-separated productID lists on one model when the same XML applies to several variants — we split and match against each. Entries without a productID attribute are always kept in the fallback list so no CV7/CV8 match is ever lost.

If any entry has a productID match, only those matches are returned. If none do, we fall back to the CV7/CV8-only list — this handles cases like a catalog scan that missed newer productIDs the operator's decoder actually reports.

class UnknownDecoderIdentityError(builtins.Exception):
340class UnknownDecoderIdentityError(Exception):
341    """CV7/CV8 read back values we can't map to a known decoder.
342
343    Distinct from "we know the mfg but have no matching decoder in the
344    catalog" (which returns an empty list). This is raised when we
345    can't even interpret the identity bytes — most often because the
346    read failed on the wire and the CS returned its NoAck sentinel.
347    """
348
349    # CV8 values that almost always mean "the physical read failed"
350    # rather than a real (but unrecognised) mfg ID:
351    #   0   — NMRA reserved / decoder didn't respond
352    #   48  — NCE PowerPro NoAck sentinel byte (0x30)
353    #   255 — 0xFF, common "read timed out" fill
354    _WIRE_FAIL_SENTINELS = {0, 48, 255}
355
356    def __init__(self, cv7: int, cv8: int) -> None:
357        if cv8 in self._WIRE_FAIL_SENTINELS:
358            hint = (
359                f"CV8={cv8} looks like a failed wire read (0/48/255 are the "
360                "common no-decoder / no-ack sentinels). Retry, or verify the "
361                "decoder is on the programming track and powered."
362            )
363        else:
364            hint = (
365                f"CV8={cv8} is not a known NMRA manufacturer ID. The catalog "
366                "may not be loaded (Load catalog above), or this is a very "
367                "new / obscure decoder."
368            )
369        super().__init__(f"CV7={cv7} CV8={cv8} — {hint}")
370        self.cv7 = cv7
371        self.cv8 = cv8

CV7/CV8 read back values we can't map to a known decoder.

Distinct from "we know the mfg but have no matching decoder in the catalog" (which returns an empty list). This is raised when we can't even interpret the identity bytes — most often because the read failed on the wire and the CS returned its NoAck sentinel.

UnknownDecoderIdentityError(cv7: int, cv8: int)
356    def __init__(self, cv7: int, cv8: int) -> None:
357        if cv8 in self._WIRE_FAIL_SENTINELS:
358            hint = (
359                f"CV8={cv8} looks like a failed wire read (0/48/255 are the "
360                "common no-decoder / no-ack sentinels). Retry, or verify the "
361                "decoder is on the programming track and powered."
362            )
363        else:
364            hint = (
365                f"CV8={cv8} is not a known NMRA manufacturer ID. The catalog "
366                "may not be loaded (Load catalog above), or this is a very "
367                "new / obscure decoder."
368            )
369        super().__init__(f"CV7={cv7} CV8={cv8} — {hint}")
370        self.cv7 = cv7
371        self.cv8 = cv8
cv7
cv8