jmri_decoderpro

jmri-decoderpro — Stage 8 of the JMRI Python rewrite.

Decoder programming catalog + session management. The 881 JMRI decoder XML files are parsed via jmri_core.persistence.decoder_xml (Stage 2) and exposed through DecoderCatalog. A ProgrammingSession binds a decoder definition to a Programmer (from the active SystemConnection) and reads/writes CVs.

 1"""jmri-decoderpro — Stage 8 of the JMRI Python rewrite.
 2
 3Decoder programming catalog + session management. The 881 JMRI decoder
 4XML files are parsed via :mod:`jmri_core.persistence.decoder_xml` (Stage
 52) and exposed through :class:`DecoderCatalog`. A
 6:class:`ProgrammingSession` binds a decoder definition to a
 7:class:`Programmer` (from the active SystemConnection) and reads/writes
 8CVs.
 9"""
10from jmri_core.persistence.programmer_xml import ProgrammerConfig
11
12from jmri_decoderpro.catalog import (
13    DecoderCatalog,
14    DecoderDefinition,
15    DecoderKey,
16    UnknownDecoderIdentityError,
17)
18from jmri_decoderpro.panes import (
19    ROSTER_ENTRY_NAME,
20    ROSTER_ENTRY_SLUG,
21    ComposedPane,
22    compose_panes,
23    find_pane_by_slug,
24    slugify_pane_name,
25)
26from jmri_decoderpro.probes import (
27    MFG_ESU,
28    MFG_SOUNDTRAXX,
29    MFG_TCS,
30    MFG_ZIMO,
31    PROBES,
32    probe_esu,
33    probe_soundtraxx,
34    probe_tcs,
35    probe_zimo,
36    read_product_id,
37)
38from jmri_decoderpro.session import CvCacheEntry, ProgrammingSession
39
40__version__ = "0.1.0"
41
42__all__ = [
43    "MFG_ESU",
44    "MFG_SOUNDTRAXX",
45    "MFG_TCS",
46    "MFG_ZIMO",
47    "PROBES",
48    "ROSTER_ENTRY_NAME",
49    "ROSTER_ENTRY_SLUG",
50    "ComposedPane",
51    "CvCacheEntry",
52    "DecoderCatalog",
53    "DecoderDefinition",
54    "DecoderKey",
55    "ProgrammerConfig",
56    "ProgrammingSession",
57    "UnknownDecoderIdentityError",
58    "__version__",
59    "compose_panes",
60    "find_pane_by_slug",
61    "probe_esu",
62    "probe_soundtraxx",
63    "probe_tcs",
64    "probe_zimo",
65    "read_product_id",
66    "slugify_pane_name",
67]
MFG_ESU = 151
MFG_SOUNDTRAXX = 141
MFG_TCS = 153
MFG_ZIMO = 145
PROBES = {141: <function probe_soundtraxx>, 151: <function probe_esu>, 145: <function probe_zimo>, 153: <function probe_tcs>}
ROSTER_ENTRY_NAME = 'Roster Entry'
ROSTER_ENTRY_SLUG = 'roster-entry'
@dataclass(frozen=True)
class ComposedPane:
 62@dataclass(frozen=True)
 63class ComposedPane:
 64    """One entry in the composed tab list.
 65
 66    * ``name`` — display label (English default)
 67    * ``slug`` — URL-safe id used in ``/programmer/loco/<id>/pane/<slug>``
 68    * ``pane`` — the underlying :class:`DecoderPane` (None for the
 69      synthetic Roster Entry tab)
 70    * ``is_synthetic`` — True for tabs the framework injects rather than
 71      pulls from XML (currently just Roster Entry)
 72    * ``source`` — where the pane came from: ``"roster"`` |
 73      ``"programmer"`` | ``"decoder"``
 74    * ``items_available`` — variable ``item`` names the loaded decoder
 75      actually provides. A subset of ``pane.items`` filtered against the
 76      decoder's variable table.
 77    """
 78
 79    name: str
 80    slug: str
 81    source: str
 82    pane: DecoderPane | None = None
 83    is_synthetic: bool = False
 84    items_available: tuple[str, ...] = field(default_factory=tuple)
 85
 86    @property
 87    def is_empty(self) -> bool:
 88        """True when the pane has no variables the decoder can serve.
 89
 90        The Roster Entry tab is never empty (it always has roster fields).
 91        A pane with ``<cvtable/>`` or ``<dccaddress/>`` is also never
 92        empty — those widgets don't depend on named variables.
 93        """
 94        if self.is_synthetic:
 95            return False
 96        if self.pane is not None and (
 97            self.pane.has_cvtable
 98            or self.pane.has_dcc_address
 99            or self.pane.has_speed_table
100            or self.pane.has_fnmapping
101        ):
102            return False
103        return not self.items_available

One entry in the composed tab list.

  • name — display label (English default)
  • slug — URL-safe id used in /programmer/loco/<id>/pane/<slug>
  • pane — the underlying DecoderPane (None for the synthetic Roster Entry tab)
  • is_synthetic — True for tabs the framework injects rather than pulls from XML (currently just Roster Entry)
  • source — where the pane came from: "roster" | "programmer" | "decoder"
  • items_available — variable item names the loaded decoder actually provides. A subset of pane.items filtered against the decoder's variable table.
ComposedPane( name: str, slug: str, source: str, pane: jmri_core.persistence.DecoderPane | None = None, is_synthetic: bool = False, items_available: tuple[str, ...] = <factory>)
name: str
slug: str
source: str
pane: jmri_core.persistence.DecoderPane | None = None
is_synthetic: bool = False
items_available: tuple[str, ...]
is_empty: bool
 86    @property
 87    def is_empty(self) -> bool:
 88        """True when the pane has no variables the decoder can serve.
 89
 90        The Roster Entry tab is never empty (it always has roster fields).
 91        A pane with ``<cvtable/>`` or ``<dccaddress/>`` is also never
 92        empty — those widgets don't depend on named variables.
 93        """
 94        if self.is_synthetic:
 95            return False
 96        if self.pane is not None and (
 97            self.pane.has_cvtable
 98            or self.pane.has_dcc_address
 99            or self.pane.has_speed_table
100            or self.pane.has_fnmapping
101        ):
102            return False
103        return not self.items_available

True when the pane has no variables the decoder can serve.

The Roster Entry tab is never empty (it always has roster fields). A pane with <cvtable/> or <dccaddress/> is also never empty — those widgets don't depend on named variables.

@dataclass
class CvCacheEntry:
33@dataclass
34class CvCacheEntry:
35    """One observed CV value + the time it was read."""
36
37    value: int
38    read_at: float       # monotonic seconds when we last read it
39    dirty: bool = False  # True if local writes haven't been pushed yet

One observed CV value + the time it was read.

CvCacheEntry(value: int, read_at: float, dirty: bool = False)
value: int
read_at: float
dirty: bool = False
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, 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.

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

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

  • panes — the standard programmer panes in include order (Basic, Motor, Basic Speed Control, ...). One entry per successful xi:include resolution.
  • unresolved_includes — hrefs that couldn't be resolved to a local file, preserved for diagnostics. An unresolved include just means that pane doesn't appear in the tab list; the rest of the programmer still works.
name: str = ''
panes: list[jmri_core.persistence.DecoderPane] = PydanticUndefined
unresolved_includes: list[str] = PydanticUndefined
source_file: pathlib._local.Path | None = None
decoder_file_panes: bool = True
show_empty_panes: str = 'default'
@dataclass
class ProgrammingSession:
 42@dataclass
 43class ProgrammingSession:
 44    """Active CV programming session."""
 45
 46    definition: DecoderDefinition
 47    programmer: Programmer
 48    cv_cache: dict[int, CvCacheEntry] = field(default_factory=dict)
 49
 50    @property
 51    def mode(self) -> ProgrammingMode:
 52        return self.programmer.mode
 53
 54    # ----- construction ----------------------------------------------------
 55
 56    @classmethod
 57    def from_global(
 58        cls, connection: SystemConnection, definition: DecoderDefinition
 59    ) -> ProgrammingSession:
 60        """Service-mode session — uses the connection's global programmer."""
 61        programmer = connection.programmers.global_programmer()
 62        if programmer is None:
 63            raise RuntimeError(
 64                f"connection {connection.name!r} has no global programmer"
 65            )
 66        return cls(definition=definition, programmer=programmer)
 67
 68    @classmethod
 69    def from_ops(
 70        cls,
 71        connection: SystemConnection,
 72        definition: DecoderDefinition,
 73        *,
 74        address: int,
 75        long_address: bool = False,
 76    ) -> ProgrammingSession:
 77        """Ops-mode session for the loco at *address*."""
 78        programmer = connection.programmers.ops_programmer(
 79            address, long_address=long_address
 80        )
 81        if programmer is None:
 82            raise RuntimeError(
 83                f"connection {connection.name!r} has no ops-mode programmer"
 84            )
 85        return cls(definition=definition, programmer=programmer)
 86
 87    # ----- CV access -------------------------------------------------------
 88
 89    async def read_cv(self, cv: int) -> int:
 90        """Read one CV, caching the result for later lookups."""
 91        value = await self.programmer.read_cv(cv)
 92        self.cv_cache[cv] = CvCacheEntry(
 93            value=value, read_at=asyncio.get_event_loop().time(), dirty=False,
 94        )
 95        return value
 96
 97    async def write_cv(self, cv: int, value: int) -> None:
 98        """Write one CV, also updating the local cache."""
 99        if not 0 <= value <= 255:
100            raise ValueError(f"CV value must be 0..255, got {value}")
101        await self.programmer.write_cv(cv, value)
102        self.cv_cache[cv] = CvCacheEntry(
103            value=value, read_at=asyncio.get_event_loop().time(), dirty=False,
104        )
105
106    async def read_all(self) -> dict[int, int]:
107        """Read every CV the decoder definition exposes. Returns a {cv: value} dict.
108
109        Variables with non-integer CV numbers (e.g., "17.18" for long-address
110        composite) are skipped — those need dedicated split-CV handling.
111        """
112        out: dict[int, int] = {}
113        for variable in self.definition.variables:
114            try:
115                cv_num = int(variable.cv)
116            except ValueError:
117                continue
118            if cv_num in out:
119                continue
120            try:
121                out[cv_num] = await self.read_cv(cv_num)
122            except Exception as e:  # noqa: BLE001 — keep reading the rest
123                _log.warning("read_all: CV%d failed (%s)", cv_num, e)
124        return out
125
126    # ----- identity --------------------------------------------------------
127
128    async def read_identity(self) -> tuple[int, int]:
129        """Read CV7 (version) and CV8 (manufacturer). Common for "detect decoder"."""
130        return await self.programmer.read_cv(7), await self.programmer.read_cv(8)
131
132    async def read_identity_extended(self) -> tuple[int, int, int | None]:
133        """Full identity: CV7 (version) + CV8 (mfg) + productID probe.
134
135        The productID probe is per-manufacturer — see
136        :mod:`jmri_decoderpro.probes`. Returns ``product_id=None``
137        when no probe is registered for the mfg, when the probe
138        determined this model doesn't populate the productID CVs,
139        or when a wire error interrupted the probe. Callers pass
140        the returned productID to
141        :meth:`~jmri_decoderpro.catalog.DecoderCatalog.identify_by_cv_extended`
142        to narrow the identify picker beyond CV7/CV8 alone.
143        """
144        from jmri_decoderpro.probes import read_product_id  # noqa: PLC0415
145
146        cv7 = await self.programmer.read_cv(7)
147        cv8 = await self.programmer.read_cv(8)
148        product_id = await read_product_id(self, mfg_id=cv8, model_id=cv7)
149        return cv7, cv8, product_id

Active CV programming session.

ProgrammingSession( definition: DecoderDefinition, programmer: jmri_core.beans.Programmer, cv_cache: dict[int, CvCacheEntry] = <factory>)
definition: DecoderDefinition
cv_cache: dict[int, CvCacheEntry]
50    @property
51    def mode(self) -> ProgrammingMode:
52        return self.programmer.mode
@classmethod
def from_global( cls, connection: jmri_core.hal.SystemConnection, definition: DecoderDefinition) -> ProgrammingSession:
56    @classmethod
57    def from_global(
58        cls, connection: SystemConnection, definition: DecoderDefinition
59    ) -> ProgrammingSession:
60        """Service-mode session — uses the connection's global programmer."""
61        programmer = connection.programmers.global_programmer()
62        if programmer is None:
63            raise RuntimeError(
64                f"connection {connection.name!r} has no global programmer"
65            )
66        return cls(definition=definition, programmer=programmer)

Service-mode session — uses the connection's global programmer.

@classmethod
def from_ops( cls, connection: jmri_core.hal.SystemConnection, definition: DecoderDefinition, *, address: int, long_address: bool = False) -> ProgrammingSession:
68    @classmethod
69    def from_ops(
70        cls,
71        connection: SystemConnection,
72        definition: DecoderDefinition,
73        *,
74        address: int,
75        long_address: bool = False,
76    ) -> ProgrammingSession:
77        """Ops-mode session for the loco at *address*."""
78        programmer = connection.programmers.ops_programmer(
79            address, long_address=long_address
80        )
81        if programmer is None:
82            raise RuntimeError(
83                f"connection {connection.name!r} has no ops-mode programmer"
84            )
85        return cls(definition=definition, programmer=programmer)

Ops-mode session for the loco at address.

async def read_cv(self, cv: int) -> int:
89    async def read_cv(self, cv: int) -> int:
90        """Read one CV, caching the result for later lookups."""
91        value = await self.programmer.read_cv(cv)
92        self.cv_cache[cv] = CvCacheEntry(
93            value=value, read_at=asyncio.get_event_loop().time(), dirty=False,
94        )
95        return value

Read one CV, caching the result for later lookups.

async def write_cv(self, cv: int, value: int) -> None:
 97    async def write_cv(self, cv: int, value: int) -> None:
 98        """Write one CV, also updating the local cache."""
 99        if not 0 <= value <= 255:
100            raise ValueError(f"CV value must be 0..255, got {value}")
101        await self.programmer.write_cv(cv, value)
102        self.cv_cache[cv] = CvCacheEntry(
103            value=value, read_at=asyncio.get_event_loop().time(), dirty=False,
104        )

Write one CV, also updating the local cache.

async def read_all(self) -> dict[int, int]:
106    async def read_all(self) -> dict[int, int]:
107        """Read every CV the decoder definition exposes. Returns a {cv: value} dict.
108
109        Variables with non-integer CV numbers (e.g., "17.18" for long-address
110        composite) are skipped — those need dedicated split-CV handling.
111        """
112        out: dict[int, int] = {}
113        for variable in self.definition.variables:
114            try:
115                cv_num = int(variable.cv)
116            except ValueError:
117                continue
118            if cv_num in out:
119                continue
120            try:
121                out[cv_num] = await self.read_cv(cv_num)
122            except Exception as e:  # noqa: BLE001 — keep reading the rest
123                _log.warning("read_all: CV%d failed (%s)", cv_num, e)
124        return out

Read every CV the decoder definition exposes. Returns a {cv: value} dict.

Variables with non-integer CV numbers (e.g., "17.18" for long-address composite) are skipped — those need dedicated split-CV handling.

async def read_identity(self) -> tuple[int, int]:
128    async def read_identity(self) -> tuple[int, int]:
129        """Read CV7 (version) and CV8 (manufacturer). Common for "detect decoder"."""
130        return await self.programmer.read_cv(7), await self.programmer.read_cv(8)

Read CV7 (version) and CV8 (manufacturer). Common for "detect decoder".

async def read_identity_extended(self) -> tuple[int, int, int | None]:
132    async def read_identity_extended(self) -> tuple[int, int, int | None]:
133        """Full identity: CV7 (version) + CV8 (mfg) + productID probe.
134
135        The productID probe is per-manufacturer — see
136        :mod:`jmri_decoderpro.probes`. Returns ``product_id=None``
137        when no probe is registered for the mfg, when the probe
138        determined this model doesn't populate the productID CVs,
139        or when a wire error interrupted the probe. Callers pass
140        the returned productID to
141        :meth:`~jmri_decoderpro.catalog.DecoderCatalog.identify_by_cv_extended`
142        to narrow the identify picker beyond CV7/CV8 alone.
143        """
144        from jmri_decoderpro.probes import read_product_id  # noqa: PLC0415
145
146        cv7 = await self.programmer.read_cv(7)
147        cv8 = await self.programmer.read_cv(8)
148        product_id = await read_product_id(self, mfg_id=cv8, model_id=cv7)
149        return cv7, cv8, product_id

Full identity: CV7 (version) + CV8 (mfg) + productID probe.

The productID probe is per-manufacturer — see jmri_decoderpro.probes. Returns product_id=None when no probe is registered for the mfg, when the probe determined this model doesn't populate the productID CVs, or when a wire error interrupted the probe. Callers pass the returned productID to ~jmri_decoderpro.catalog.DecoderCatalog.identify_by_cv_extended() to narrow the identify picker beyond CV7/CV8 alone.

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
__version__ = '0.1.0'
def compose_panes( variables: list[jmri_core.persistence.DecoderVariable] | tuple[jmri_core.persistence.DecoderVariable, ...], programmer: ProgrammerConfig | None, decoder_panes: list[jmri_core.persistence.DecoderPane] | tuple[jmri_core.persistence.DecoderPane, ...] = (), *, show_empty: bool | None = None) -> list[ComposedPane]:
106def compose_panes(
107    variables: list[DecoderVariable] | tuple[DecoderVariable, ...],
108    programmer: ProgrammerConfig | None,
109    decoder_panes: list[DecoderPane] | tuple[DecoderPane, ...] = (),
110    *,
111    show_empty: bool | None = None,
112) -> list[ComposedPane]:
113    """Build the ordered tab list for a loco's programmer view.
114
115    Behaviour:
116
117    1. Synthetic ``Roster Entry`` tab always first.
118    2. Standard tabs from *programmer*, filtered against
119       ``_STANDARD_TAB_ORDER``. Any standard tab the programmer doesn't
120       declare is skipped.
121    3. Any decoder-declared pane from *decoder_panes* that isn't already
122       covered by name lands after the standard set.
123    4. Empty panes are removed unless *show_empty* forces them in
124       (mirrors JMRI's ``showEmptyPanes`` semantics; default follows the
125       programmer's own setting when *show_empty* is None).
126    """
127    if show_empty is None and programmer is not None:
128        show_empty = programmer.show_empty_panes.lower() == "yes"
129    if show_empty is None:
130        show_empty = False
131
132    decoder_items = {v.item for v in variables if v.item}
133
134    out: list[ComposedPane] = [
135        ComposedPane(
136            name=ROSTER_ENTRY_NAME,
137            slug=ROSTER_ENTRY_SLUG,
138            source="roster",
139            is_synthetic=True,
140        )
141    ]
142
143    # Decoder-declared panes take priority over the standard programmer
144    # pane with the same name — SoundTraxx Tsunami2 ships its own
145    # "Function Map" pane (Extended Function Map) that replaces
146    # Comprehensive.xml's generic ``<fnmapping/>`` matrix, ESU/Zimo do
147    # the same for their model-specific tabs, etc. Index the
148    # decoder-declared panes by name up front so the standard-pane pass
149    # can substitute them in place, preserving the standard tab order.
150    decoder_by_name: dict[str, DecoderPane] = {}
151    include_decoder_panes = programmer is None or programmer.decoder_file_panes
152    if include_decoder_panes:
153        for pane in decoder_panes:
154            if pane.name and pane.name not in _DROPPED_TAB_NAMES:
155                decoder_by_name.setdefault(pane.name, pane)
156
157    # Standard panes — declared by the programmer, ordered by spec.
158    programmer_by_name: dict[str, DecoderPane] = {}
159    if programmer is not None:
160        for pane in programmer.panes:
161            if pane.name and pane.name not in _DROPPED_TAB_NAMES:
162                programmer_by_name.setdefault(pane.name, pane)
163
164    seen_slugs: set[str] = {ROSTER_ENTRY_SLUG}
165    for standard_name in _STANDARD_TAB_ORDER:
166        pane = programmer_by_name.pop(standard_name, None)
167        if pane is None:
168            continue
169        # Prefer the decoder-declared variant when it exists.
170        override = decoder_by_name.pop(standard_name, None)
171        if override is not None:
172            composed = _compose_one(override, "decoder", decoder_items)
173        else:
174            composed = _compose_one(pane, "programmer", decoder_items)
175        if composed.is_empty and not show_empty:
176            continue
177        seen_slugs.add(composed.slug)
178        out.append(composed)
179
180    # Programmer-declared panes that don't match the fixed spec order —
181    # append after the standard block so downstream users still see them.
182    for pane in programmer_by_name.values():
183        override = decoder_by_name.pop(pane.name, None)
184        if override is not None:
185            composed = _compose_one(override, "decoder", decoder_items)
186        else:
187            composed = _compose_one(pane, "programmer", decoder_items)
188        if composed.is_empty and not show_empty:
189            continue
190        if composed.slug in seen_slugs:
191            continue
192        seen_slugs.add(composed.slug)
193        out.append(composed)
194
195    # Remaining decoder-declared panes — ones with no matching standard
196    # tab. Append after the standard block.
197    seen_names = {c.name for c in out}
198    for pane in decoder_by_name.values():
199        if pane.name in seen_names:
200            continue
201        composed = _compose_one(pane, "decoder", decoder_items)
202        if composed.is_empty and not show_empty:
203            continue
204        if composed.slug in seen_slugs:
205            continue
206        seen_slugs.add(composed.slug)
207        seen_names.add(composed.name)
208        out.append(composed)
209
210    return out

Build the ordered tab list for a loco's programmer view.

Behaviour:

  1. Synthetic Roster Entry tab always first.
  2. Standard tabs from programmer, filtered against _STANDARD_TAB_ORDER. Any standard tab the programmer doesn't declare is skipped.
  3. Any decoder-declared pane from decoder_panes that isn't already covered by name lands after the standard set.
  4. Empty panes are removed unless show_empty forces them in (mirrors JMRI's showEmptyPanes semantics; default follows the programmer's own setting when show_empty is None).
def find_pane_by_slug( panes: list[ComposedPane], slug: str) -> ComposedPane | None:
252def find_pane_by_slug(
253    panes: list[ComposedPane], slug: str,
254) -> ComposedPane | None:
255    for p in panes:
256        if p.slug == slug:
257            return p
258    return None
async def probe_esu( session: ProgrammingSession, mfg_id: int, model_id: int) -> int | None:
107async def probe_esu(
108    session: ProgrammingSession, mfg_id: int, model_id: int,
109) -> int | None:
110    """ESU (Lok family) productID probe.
111
112    Only fires for LokPilot v4+ / LokSound v4+ (``model_id == 255``);
113    older ESU decoders don't have the extended-ID CVs.
114
115    Two index CVs (CV31 / CV32) select the "extended-identifier"
116    page, then 4 bytes at CV261..264 make up a little-endian
117    productID (from ``IdentifyDecoder.java`` lines 331..458)::
118
119        write CV31 = 0
120        write CV32 = 255
121        productID = CV261 | (CV262 << 8) | (CV263 << 16) | (CV264 << 24)
122    """
123    if model_id != 255:
124        return None
125    # These writes route subsequent reads through the extended
126    # identifier page. Failing writes are non-fatal — some ops-mode
127    # programmers can't do the page-switch and the caller falls
128    # back to family-level ID.
129    await session.programmer.write_cv(31, 0)
130    await session.programmer.write_cv(32, 255)
131    b0 = await session.programmer.read_cv(261)
132    b1 = await session.programmer.read_cv(262)
133    b2 = await session.programmer.read_cv(263)
134    b3 = await session.programmer.read_cv(264)
135    return b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)

ESU (Lok family) productID probe.

Only fires for LokPilot v4+ / LokSound v4+ (model_id == 255); older ESU decoders don't have the extended-ID CVs.

Two index CVs (CV31 / CV32) select the "extended-identifier" page, then 4 bytes at CV261..264 make up a little-endian productID (from IdentifyDecoder.java lines 331..458)::

write CV31 = 0
write CV32 = 255
productID = CV261 | (CV262 << 8) | (CV263 << 16) | (CV264 << 24)
async def probe_soundtraxx( session: ProgrammingSession, mfg_id: int, model_id: int) -> int | None:
 81async def probe_soundtraxx(
 82    session: ProgrammingSession, mfg_id: int, model_id: int,
 83) -> int | None:
 84    """SoundTraxx productID probe.
 85
 86    Only fires for Tsunami2 family (``model_id`` in 70..72); older
 87    Tsunami / Econami decoders don't populate the productID CVs.
 88
 89    Byte-packing (from ``IdentifyDecoder.java`` lines 262..380)::
 90
 91        highest = CV253
 92        low     = CV256
 93        high    = CV255
 94        productID = low | ((high & 0x7) << 8) | (highest << 11)
 95    """
 96    if not (70 <= model_id <= 72):
 97        return None
 98    highest = await session.programmer.read_cv(253)
 99    low = await session.programmer.read_cv(256)
100    high = await session.programmer.read_cv(255)
101    return low | ((high & 0x7) << 8) | (highest << 11)

SoundTraxx productID probe.

Only fires for Tsunami2 family (model_id in 70..72); older Tsunami / Econami decoders don't populate the productID CVs.

Byte-packing (from IdentifyDecoder.java lines 262..380)::

highest = CV253
low     = CV256
high    = CV255
productID = low | ((high & 0x7) << 8) | (highest << 11)
async def probe_tcs( session: ProgrammingSession, mfg_id: int, model_id: int) -> int | None:
151async def probe_tcs(
152    session: ProgrammingSession, mfg_id: int, model_id: int,
153) -> int | None:
154    """Train Control Systems (TCS) productID probe.
155
156    TCS uses CV249 as the primary ID; older decoders (< 129) stop
157    there. Newer decoders extend the ID across CV248 / CV111 / CV110
158    with a family-specific packing formula (from
159    ``IdentifyDecoder.java`` lines 227..426).
160
161    Short form (CV249 < 129)::
162
163        productID = CV249
164
165    Extended form (CV249 ≥ 129)::
166
167        lowest = CV249; low = CV248; high = CV111; highest = CV110
168        # Which packing applies depends on ``model_id`` (from CV7) and
169        # the observed lowest byte — matches the branching in
170        # ``IdentifyDecoder.test7`` verbatim.
171    """
172    lowest = await session.programmer.read_cv(249)
173    if lowest < 129:
174        return lowest
175
176    low = await session.programmer.read_cv(248)
177    high = await session.programmer.read_cv(111)
178    highest = await session.programmer.read_cv(110)
179
180    # See ``IdentifyDecoder.test7`` (Java lines 414..425) for the
181    # authoritative branching. Kept as a nested if/else to make the
182    # correspondence with the Java source obvious to a reader who
183    # cross-checks; a table-driven form would obscure the mapping.
184    if (
185        (129 <= lowest <= 135 and low == 5)
186        or model_id >= 5
187    ):
188        if lowest == 180 and model_id == 5:
189            return lowest + low * 256
190        return (
191            lowest + low * 256 + high * 65536 + highest * 16777216
192        )
193    if (
194        (129 <= lowest <= 135)
195        or (170 <= lowest <= 172)
196        or lowest == 180
197    ) and model_id == 4:
198        return lowest + low * 256
199    return lowest

Train Control Systems (TCS) productID probe.

TCS uses CV249 as the primary ID; older decoders (< 129) stop there. Newer decoders extend the ID across CV248 / CV111 / CV110 with a family-specific packing formula (from IdentifyDecoder.java lines 227..426).

Short form (CV249 < 129)::

productID = CV249

Extended form (CV249 ≥ 129)::

lowest = CV249; low = CV248; high = CV111; highest = CV110
# Which packing applies depends on ``model_id`` (from CV7) and
# the observed lowest byte — matches the branching in
# ``IdentifyDecoder.test7`` verbatim.
async def probe_zimo( session: ProgrammingSession, mfg_id: int, model_id: int) -> int | None:
141async def probe_zimo(
142    session: ProgrammingSession, mfg_id: int, model_id: int,
143) -> int | None:
144    """Zimo productID probe — CV250 holds a single-byte decoder ID."""
145    return await session.programmer.read_cv(250)

Zimo productID probe — CV250 holds a single-byte decoder ID.

async def read_product_id( session: ProgrammingSession, mfg_id: int, model_id: int) -> int | None:
213async def read_product_id(
214    session: ProgrammingSession, mfg_id: int, model_id: int,
215) -> int | None:
216    """Look up the probe for *mfg_id* and run it.
217
218    Returns ``None`` when:
219
220    * no probe is registered for the manufacturer, or
221    * the probe determined the model doesn't populate productID CVs
222      (e.g. older SoundTraxx decoders), or
223    * a wire-level error prevented any CV from being read — the
224      caller falls back to family-level identify in that case.
225
226    The error path treats a raised exception during a probe as a
227    non-fatal miss: identify continues with just CV7+CV8. That
228    matches JMRI Java's behaviour of continuing after an optional
229    CV read fails.
230    """
231    probe = PROBES.get(mfg_id)
232    if probe is None:
233        return None
234    try:
235        return await probe(session, mfg_id, model_id)
236    except Exception as e:  # noqa: BLE001
237        _log.warning(
238            "productID probe for mfg=%d model=%d failed: %s",
239            mfg_id, model_id, e,
240        )
241        return None

Look up the probe for mfg_id and run it.

Returns None when:

  • no probe is registered for the manufacturer, or
  • the probe determined the model doesn't populate productID CVs (e.g. older SoundTraxx decoders), or
  • a wire-level error prevented any CV from being read — the caller falls back to family-level identify in that case.

The error path treats a raised exception during a probe as a non-fatal miss: identify continues with just CV7+CV8. That matches JMRI Java's behaviour of continuing after an optional CV read fails.

def slugify_pane_name(name: str) -> str:
245def slugify_pane_name(name: str) -> str:
246    """Turn a pane display name into a URL-safe slug."""
247    lower = name.strip().lower()
248    slug = _SLUG_STRIP.sub("-", lower).strip("-")
249    return slug or "pane"

Turn a pane display name into a URL-safe slug.