jmri_decoderpro.session
Programming session — binds a decoder definition + programmer.
A ProgrammingSession is the top-level handle DecoderPro hands
to clients. It knows:
- which
DecoderDefinitionis on the programming track (or the ops-mode loco being modified); - which
Programmerto drive; - the current cache of CV values read so far.
Two flavors:
from_global()— service-mode session against the programming track. Reads work on any decoder.from_ops()— ops-mode session against a running loco; reads only succeed with RailCom-capable hardware.
1"""Programming session — binds a decoder definition + programmer. 2 3A :class:`ProgrammingSession` is the top-level handle DecoderPro hands 4to clients. It knows: 5 6* which :class:`DecoderDefinition` is on the programming track (or the 7 ops-mode loco being modified); 8* which :class:`Programmer` to drive; 9* the current cache of CV values read so far. 10 11Two flavors: 12 13* :meth:`from_global` — service-mode session against the programming 14 track. Reads work on any decoder. 15* :meth:`from_ops` — ops-mode session against a running loco; reads 16 only succeed with RailCom-capable hardware. 17""" 18from __future__ import annotations 19 20import asyncio 21import logging 22from dataclasses import dataclass, field 23 24from jmri_core.beans.programmer import Programmer, ProgrammingMode 25from jmri_core.hal.connection import SystemConnection 26 27from jmri_decoderpro.catalog import DecoderDefinition 28 29_log = logging.getLogger(__name__) 30 31 32@dataclass 33class CvCacheEntry: 34 """One observed CV value + the time it was read.""" 35 36 value: int 37 read_at: float # monotonic seconds when we last read it 38 dirty: bool = False # True if local writes haven't been pushed yet 39 40 41@dataclass 42class ProgrammingSession: 43 """Active CV programming session.""" 44 45 definition: DecoderDefinition 46 programmer: Programmer 47 cv_cache: dict[int, CvCacheEntry] = field(default_factory=dict) 48 49 @property 50 def mode(self) -> ProgrammingMode: 51 return self.programmer.mode 52 53 # ----- construction ---------------------------------------------------- 54 55 @classmethod 56 def from_global( 57 cls, connection: SystemConnection, definition: DecoderDefinition 58 ) -> ProgrammingSession: 59 """Service-mode session — uses the connection's global programmer.""" 60 programmer = connection.programmers.global_programmer() 61 if programmer is None: 62 raise RuntimeError( 63 f"connection {connection.name!r} has no global programmer" 64 ) 65 return cls(definition=definition, programmer=programmer) 66 67 @classmethod 68 def from_ops( 69 cls, 70 connection: SystemConnection, 71 definition: DecoderDefinition, 72 *, 73 address: int, 74 long_address: bool = False, 75 ) -> ProgrammingSession: 76 """Ops-mode session for the loco at *address*.""" 77 programmer = connection.programmers.ops_programmer( 78 address, long_address=long_address 79 ) 80 if programmer is None: 81 raise RuntimeError( 82 f"connection {connection.name!r} has no ops-mode programmer" 83 ) 84 return cls(definition=definition, programmer=programmer) 85 86 # ----- CV access ------------------------------------------------------- 87 88 async def read_cv(self, cv: int) -> int: 89 """Read one CV, caching the result for later lookups.""" 90 value = await self.programmer.read_cv(cv) 91 self.cv_cache[cv] = CvCacheEntry( 92 value=value, read_at=asyncio.get_event_loop().time(), dirty=False, 93 ) 94 return value 95 96 async def write_cv(self, cv: int, value: int) -> None: 97 """Write one CV, also updating the local cache.""" 98 if not 0 <= value <= 255: 99 raise ValueError(f"CV value must be 0..255, got {value}") 100 await self.programmer.write_cv(cv, value) 101 self.cv_cache[cv] = CvCacheEntry( 102 value=value, read_at=asyncio.get_event_loop().time(), dirty=False, 103 ) 104 105 async def read_all(self) -> dict[int, int]: 106 """Read every CV the decoder definition exposes. Returns a {cv: value} dict. 107 108 Variables with non-integer CV numbers (e.g., "17.18" for long-address 109 composite) are skipped — those need dedicated split-CV handling. 110 """ 111 out: dict[int, int] = {} 112 for variable in self.definition.variables: 113 try: 114 cv_num = int(variable.cv) 115 except ValueError: 116 continue 117 if cv_num in out: 118 continue 119 try: 120 out[cv_num] = await self.read_cv(cv_num) 121 except Exception as e: # noqa: BLE001 — keep reading the rest 122 _log.warning("read_all: CV%d failed (%s)", cv_num, e) 123 return out 124 125 # ----- identity -------------------------------------------------------- 126 127 async def read_identity(self) -> tuple[int, int]: 128 """Read CV7 (version) and CV8 (manufacturer). Common for "detect decoder".""" 129 return await self.programmer.read_cv(7), await self.programmer.read_cv(8) 130 131 async def read_identity_extended(self) -> tuple[int, int, int | None]: 132 """Full identity: CV7 (version) + CV8 (mfg) + productID probe. 133 134 The productID probe is per-manufacturer — see 135 :mod:`jmri_decoderpro.probes`. Returns ``product_id=None`` 136 when no probe is registered for the mfg, when the probe 137 determined this model doesn't populate the productID CVs, 138 or when a wire error interrupted the probe. Callers pass 139 the returned productID to 140 :meth:`~jmri_decoderpro.catalog.DecoderCatalog.identify_by_cv_extended` 141 to narrow the identify picker beyond CV7/CV8 alone. 142 """ 143 from jmri_decoderpro.probes import read_product_id # noqa: PLC0415 144 145 cv7 = await self.programmer.read_cv(7) 146 cv8 = await self.programmer.read_cv(8) 147 product_id = await read_product_id(self, mfg_id=cv8, model_id=cv7) 148 return cv7, cv8, product_id
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.
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.
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.
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.
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.
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.
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.
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".
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.