jmri_server.loco_cv_store
LocoCvStore — lazy per-locomotive CV-state cache backed by disk.
Two on-disk layouts can carry a loco's CV state:
- JMRI-canonical: the
<values>block embedded in<profile>/roster/<id>.xml(the locomotive-config file itself). This is what JMRI Java reads and writes. - Legacy proprietary:
<profile>/rosters/<id>.xmlwith our own<locomotive-cv-values>root. Used by earlier alphas.
On read we try the canonical location first; if it has no <values>
we fall back to the legacy one. When both have CV data, we raise
AmbiguousCvSourceError — the UI catches that and prompts the
user to pick which source is authoritative. Once resolved (or on a fresh
loco), writes go to the location we loaded from, defaulting to the
JMRI-canonical embedded <values> block for interop.
Storage policy is otherwise unchanged:
- In-memory state is the source of truth during a session.
- Files are the source of truth across server restarts.
- Writes are atomic (tmp + rename).
- Failures are logged, not raised, so a permissions issue doesn't crash the programmer page mid-session.
1"""``LocoCvStore`` — lazy per-locomotive CV-state cache backed by disk. 2 3Two on-disk layouts can carry a loco's CV state: 4 5* **JMRI-canonical**: the ``<values>`` block embedded in 6 ``<profile>/roster/<id>.xml`` (the locomotive-config file itself). 7 This is what JMRI Java reads and writes. 8* **Legacy proprietary**: ``<profile>/rosters/<id>.xml`` with our own 9 ``<locomotive-cv-values>`` root. Used by earlier alphas. 10 11On read we try the canonical location first; if it has no ``<values>`` 12we fall back to the legacy one. When *both* have CV data, we raise 13:class:`AmbiguousCvSourceError` — the UI catches that and prompts the 14user to pick which source is authoritative. Once resolved (or on a fresh 15loco), writes go to the location we loaded from, defaulting to the 16JMRI-canonical embedded ``<values>`` block for interop. 17 18Storage policy is otherwise unchanged: 19 20* In-memory state is the source of truth during a session. 21* Files are the source of truth across server restarts. 22* Writes are atomic (tmp + rename). 23* Failures are logged, not raised, so a permissions issue doesn't 24 crash the programmer page mid-session. 25""" 26from __future__ import annotations 27 28import logging 29import pathlib 30import re 31import shutil 32from collections.abc import Callable 33from dataclasses import dataclass, field 34from enum import StrEnum 35 36from jmri_core.beans.loco_cv_state import LocoCvState 37from jmri_core.persistence.loco_cv_xml import ( 38 load_cv_values_from_locomotive_config, 39 load_loco_cv, 40 save_cv_values_into_locomotive_config, 41) 42from jmri_core.persistence.profile import Profile 43 44# ``(loco_id) → {cv_number_str: default_value_str}``. Consulted by 45# :meth:`LocoCvStore.set_cv` when deciding what to persist: any CV whose 46# in-memory value equals its catalog default is *dropped* from the 47# roster ``<values>`` block on write, per the spec (roster stores only 48# user customisations). Returning an empty dict disables filtering for 49# that loco (catalog not yet loaded etc.). 50DefaultLookup = Callable[[str], dict[str, str]] 51 52_log = logging.getLogger(__name__) 53_SAFE_FILENAME = re.compile(r"[^A-Za-z0-9._-]+") 54 55 56class CvSource(StrEnum): 57 """Where a loco's CV state is / should be persisted.""" 58 ROSTER = "roster" # JMRI-canonical: <profile>/roster/<id>.xml <values> 59 LEGACY = "legacy" # our layout: <profile>/rosters/<id>.xml 60 61 62class AmbiguousCvSourceError(Exception): 63 """Both source locations have CV data for the same loco. 64 65 The UI catches this and prompts the user which source is authoritative 66 before proceeding. Once chosen, the picked source is remembered on 67 :class:`LocoCvStore` so future accesses don't re-prompt. 68 """ 69 70 def __init__( 71 self, loco_id: str, 72 roster_path: pathlib.Path, legacy_path: pathlib.Path, 73 ) -> None: 74 super().__init__( 75 f"CV state for loco {loco_id!r} exists in both " 76 f"{roster_path} and {legacy_path}; caller must pick one via " 77 f"LocoCvStore.set_preferred_source()", 78 ) 79 self.loco_id = loco_id 80 self.roster_path = roster_path 81 self.legacy_path = legacy_path 82 83 84def _safe_filename(loco_id: str) -> str: 85 """Sanitize a loco id into a filesystem-safe filename stem.""" 86 cleaned = _SAFE_FILENAME.sub("_", loco_id).strip("_") or "entry" 87 return f"{cleaned}.xml" 88 89 90def _resolve_filename(loco_id: str, file_name: str | None) -> str: 91 """Pick the CV-state filename for *loco_id*. 92 93 JMRI's roster stores a ``fileName`` attribute on each locomotive 94 (often a small integer like ``1.xml``) — when set, that's the file 95 JMRI uses for decoder state, and that's the file our programmer 96 must read/write. Falls back to a sanitised ``<id>.xml`` when the 97 roster doesn't pin a filename. 98 """ 99 if file_name: 100 # Only the basename; never let a caller traverse out of the dir. 101 return pathlib.Path(file_name).name 102 return _safe_filename(loco_id) 103 104 105@dataclass 106class LocoCvStore: 107 """Lazy disk-backed map of loco-id → :class:`LocoCvState`. 108 109 ``default_lookup`` is optional; when supplied, writes filter out any 110 CV whose value matches the catalog default so ``roster/<id>.xml`` 111 stays a minimal delta. See :data:`DefaultLookup` for the callable's 112 signature. Set to ``None`` for legacy behaviour (write every CV). 113 """ 114 115 profile: Profile 116 default_lookup: DefaultLookup | None = None 117 _by_id: dict[str, LocoCvState] = field(default_factory=dict) 118 # loco_id → chosen source. Set via ``set_preferred_source`` when the 119 # UI resolves an ambiguity, or automatically when only one source 120 # has data. 121 _source_by_id: dict[str, CvSource] = field(default_factory=dict) 122 # Roster files we've already backed up during this session. 123 # Absolute paths so switching a loco's ``fileName`` still triggers 124 # a fresh backup on the new file. See :meth:`_backup_once`. 125 _backed_up_paths: set[pathlib.Path] = field(default_factory=set) 126 127 def __post_init__(self) -> None: 128 # Both locations are created lazily on first write. Not 129 # pre-scanned — the programmer opens for one loco at a time. 130 self.profile.rosters_cv_dir.mkdir(parents=True, exist_ok=True) 131 self.profile.roster_dir.mkdir(parents=True, exist_ok=True) 132 133 # ----- source resolution ------------------------------------------ 134 135 def _roster_path(self, loco_id: str, file_name: str | None) -> pathlib.Path: 136 """The JMRI-canonical file: ``<profile>/roster/<filename>``.""" 137 return self.profile.roster_dir / _resolve_filename(loco_id, file_name) 138 139 def _legacy_path(self, loco_id: str, file_name: str | None) -> pathlib.Path: 140 """Our proprietary file: ``<profile>/rosters/<filename>``.""" 141 return self.profile.rosters_cv_dir / _resolve_filename(loco_id, file_name) 142 143 def path_for( 144 self, loco_id: str, file_name: str | None = None, 145 source: CvSource | None = None, 146 ) -> pathlib.Path: 147 """Resolve the file path for *loco_id* under *source*. 148 149 Backwards-compat with older callers that assumed the legacy 150 location: when *source* is None, use the loco's preferred source 151 if known, else default to the legacy path. 152 """ 153 source = source or self._source_by_id.get(loco_id, CvSource.LEGACY) 154 if source is CvSource.ROSTER: 155 return self._roster_path(loco_id, file_name) 156 return self._legacy_path(loco_id, file_name) 157 158 def _backup_once(self, roster_path: pathlib.Path) -> None: 159 """Copy *roster_path* to ``<name>.xml.bak`` the first time the 160 session touches it. 161 162 No-op if: 163 164 * the path has already been backed up during this store's life; 165 * the source file doesn't exist (nothing to back up); 166 * the copy raises :class:`OSError` (permissions / filesystem 167 full) — logged so a follow-up sync can see the failure, but 168 the write itself proceeds. 169 """ 170 abs_path = roster_path.resolve() if roster_path.exists() else roster_path 171 if abs_path in self._backed_up_paths: 172 return 173 # Mark first so a failure to copy doesn't cause a retry storm 174 # on every subsequent write. 175 self._backed_up_paths.add(abs_path) 176 if not roster_path.exists(): 177 return 178 backup_path = roster_path.with_suffix(roster_path.suffix + ".bak") 179 try: 180 shutil.copy2(roster_path, backup_path) 181 _log.info( 182 "LocoCvStore: backed up %s → %s", roster_path, backup_path, 183 ) 184 except OSError as e: 185 _log.warning( 186 "LocoCvStore: backup %s → %s failed: %s", 187 roster_path, backup_path, e, 188 ) 189 190 def set_preferred_source( 191 self, loco_id: str, source: CvSource, 192 ) -> None: 193 """Record which source the UI wants us to use for this loco. 194 195 Clears any cached in-memory state so the next :meth:`get` reads 196 the freshly-picked source from disk. 197 """ 198 self._source_by_id[loco_id] = source 199 self._by_id.pop(loco_id, None) 200 201 # ----- read -------------------------------------------------------- 202 203 def get(self, loco_id: str, file_name: str | None = None) -> LocoCvState: 204 """Return the loco's CV state, loading from disk on first hit. 205 206 Resolution order: 207 208 1. In-memory cache 209 2. Preferred source set via :meth:`set_preferred_source` 210 3. JMRI-canonical ``roster/<id>.xml`` ``<values>`` block 211 4. Legacy ``rosters/<id>.xml`` 212 5. Empty state if none of the above have CVs 213 214 Raises :class:`AmbiguousCvSourceError` when both canonical and 215 legacy locations have CV data and the preferred source hasn't 216 been pinned yet. 217 """ 218 if loco_id in self._by_id: 219 return self._by_id[loco_id] 220 221 pref = self._source_by_id.get(loco_id) 222 if pref is CvSource.ROSTER: 223 state = self._read_roster(loco_id, file_name) or LocoCvState(loco_id=loco_id) 224 elif pref is CvSource.LEGACY: 225 state = self._read_legacy(loco_id, file_name) or LocoCvState(loco_id=loco_id) 226 else: 227 roster_state = self._read_roster(loco_id, file_name) 228 legacy_state = self._read_legacy(loco_id, file_name) 229 if roster_state and legacy_state and ( 230 roster_state.cv_values and legacy_state.cv_values 231 ): 232 raise AmbiguousCvSourceError( 233 loco_id, 234 self._roster_path(loco_id, file_name), 235 self._legacy_path(loco_id, file_name), 236 ) 237 if roster_state and roster_state.cv_values: 238 self._source_by_id[loco_id] = CvSource.ROSTER 239 state = roster_state 240 elif legacy_state: 241 self._source_by_id[loco_id] = CvSource.LEGACY 242 state = legacy_state 243 elif roster_state is not None: 244 # Roster entry file exists but has no <values> — write 245 # to JMRI-canonical on next set_cv for best interop. 246 self._source_by_id[loco_id] = CvSource.ROSTER 247 state = roster_state 248 else: 249 # Neither exists yet — default new locos to canonical. 250 self._source_by_id[loco_id] = CvSource.ROSTER 251 state = LocoCvState(loco_id=loco_id) 252 253 # File-stored ids might not match what the caller passed (JMRI 254 # ``fileName`` decouples filename from loco id). Canonicalise. 255 if state.loco_id != loco_id: 256 state = LocoCvState(loco_id=loco_id, cv_values=dict(state.cv_values)) 257 self._by_id[loco_id] = state 258 return state 259 260 def _read_roster( 261 self, loco_id: str, file_name: str | None, 262 ) -> LocoCvState | None: 263 """Try to read CV state from ``roster/<id>.xml`` (JMRI-canonical).""" 264 path = self._roster_path(loco_id, file_name) 265 if not path.exists(): 266 return None 267 try: 268 return load_cv_values_from_locomotive_config(path, loco_id=loco_id) 269 except Exception as e: # noqa: BLE001 270 _log.warning("LocoCvStore: failed to read %s: %s", path, e) 271 return None 272 273 def _read_legacy( 274 self, loco_id: str, file_name: str | None, 275 ) -> LocoCvState | None: 276 """Try to read CV state from ``rosters/<id>.xml`` (our layout).""" 277 path = self._legacy_path(loco_id, file_name) 278 if not path.exists(): 279 return None 280 try: 281 return load_loco_cv(path) 282 except Exception as e: # noqa: BLE001 283 _log.warning("LocoCvStore: failed to read %s: %s", path, e) 284 return None 285 286 # ----- write ------------------------------------------------------- 287 288 def record_read_result( 289 self, loco_id: str, cv: str, value: int, file_name: str | None = None, 290 ) -> LocoCvState: 291 """Record a freshly-read CV in the in-memory cache. Does NOT 292 persist to disk and does NOT trigger a ``.bak``. 293 294 JMRI Java treats reads as transient until the user explicitly 295 saves. Mirroring that (rather than our older persist-every-read 296 behaviour) means: 297 298 * The ``.bak`` created by :meth:`_backup_once` captures the 299 pre-modification state — a Read pane that touches 28 CVs 300 doesn't burn the backup on a read that leaves the roster 301 logically unchanged. 302 * SD cards on Pi setups don't take a write per CV read (the 303 Read pane action does ~30 reads sequentially). 304 * A restart discards the read cache — but that's exactly the 305 "reads are ephemeral" contract JMRI Java exposes. 306 307 Callers who want the read to persist (e.g. a "Save read 308 results" button, or a batch that logically commits reads) can 309 call :meth:`set_cv` with the same value. 310 """ 311 state = self.get(loco_id, file_name) 312 new_cvs = dict(state.cv_values) 313 new_cvs[str(cv)] = int(value) 314 new_state = LocoCvState(loco_id=loco_id, cv_values=new_cvs) 315 self._by_id[loco_id] = new_state 316 return new_state 317 318 def set_cv( 319 self, loco_id: str, cv: str, value: int, file_name: str | None = None, 320 ) -> LocoCvState: 321 """Set one CV value; persist to the JMRI-canonical 322 ``roster/<id>.xml`` ``<values>`` block, filtering CVs equal to 323 the catalog default. Returns the updated state. 324 325 Filtering behaviour depends on :attr:`default_lookup`: 326 327 * When supplied and it returns a non-empty dict for this loco, 328 any CV whose value matches the default is *dropped* from what 329 gets written — the roster file stays a minimal delta. 330 * When missing or empty, every in-memory CV is written (backward 331 compatible with the pre-refactor behaviour). 332 333 For interop with JMRI Java, writes always target the embedded 334 ``<values>`` block inside ``roster/<id>.xml``. The old 335 proprietary ``rosters/<id>.xml`` remains read-only for the 336 migration window; explicit ``CvSource.LEGACY`` preferences from 337 the ambiguity chooser also read from there but write forward to 338 the canonical location. 339 """ 340 state = self.get(loco_id, file_name) 341 new_cvs = dict(state.cv_values) 342 new_cvs[str(cv)] = int(value) 343 new_state = LocoCvState(loco_id=loco_id, cv_values=new_cvs) 344 self._by_id[loco_id] = new_state 345 346 # Apply the default-diff filter: drop any CV whose stringified 347 # value equals the catalog default. Comparison is done on 348 # strings so we sidestep numeric edge cases (e.g. "0"/"00"). 349 defaults: dict[str, str] = {} 350 if self.default_lookup is not None: 351 try: 352 defaults = self.default_lookup(loco_id) or {} 353 except Exception as e: # noqa: BLE001 354 _log.warning( 355 "LocoCvStore: default lookup failed for %r: %s", loco_id, e, 356 ) 357 to_persist = { 358 cv_name: cv_val for cv_name, cv_val in new_cvs.items() 359 if str(cv_val) != defaults.get(cv_name) 360 } 361 362 # Write to JMRI-canonical roster/<id>.xml <values> block. 363 roster_path = self._roster_path(loco_id, file_name) 364 # JMRI Java behaviour: back up the pristine roster file to 365 # ``<id>.xml.bak`` once, before the first write of the session. 366 # Repeated writes to the same file in the same session don't 367 # re-backup (otherwise the backup would race behind current 368 # state and lose the pre-session baseline). Backup is 369 # best-effort — a failure here logs but doesn't block the 370 # write, so a read-only ``.bak`` (e.g. permissions on an SD 371 # card) can't prevent programming. 372 self._backup_once(roster_path) 373 try: 374 save_cv_values_into_locomotive_config( 375 roster_path, to_persist, loco_id=loco_id, 376 ) 377 except OSError as e: 378 _log.warning("LocoCvStore: failed to write %s: %s", roster_path, e) 379 380 # Future reads should hit the roster path so the just-written 381 # values come back on a re-open even if legacy still has stale 382 # data from a previous alpha. 383 self._source_by_id[loco_id] = CvSource.ROSTER 384 return new_state
57class CvSource(StrEnum): 58 """Where a loco's CV state is / should be persisted.""" 59 ROSTER = "roster" # JMRI-canonical: <profile>/roster/<id>.xml <values> 60 LEGACY = "legacy" # our layout: <profile>/rosters/<id>.xml
Where a loco's CV state is / should be persisted.
63class AmbiguousCvSourceError(Exception): 64 """Both source locations have CV data for the same loco. 65 66 The UI catches this and prompts the user which source is authoritative 67 before proceeding. Once chosen, the picked source is remembered on 68 :class:`LocoCvStore` so future accesses don't re-prompt. 69 """ 70 71 def __init__( 72 self, loco_id: str, 73 roster_path: pathlib.Path, legacy_path: pathlib.Path, 74 ) -> None: 75 super().__init__( 76 f"CV state for loco {loco_id!r} exists in both " 77 f"{roster_path} and {legacy_path}; caller must pick one via " 78 f"LocoCvStore.set_preferred_source()", 79 ) 80 self.loco_id = loco_id 81 self.roster_path = roster_path 82 self.legacy_path = legacy_path
Both source locations have CV data for the same loco.
The UI catches this and prompts the user which source is authoritative
before proceeding. Once chosen, the picked source is remembered on
LocoCvStore so future accesses don't re-prompt.
71 def __init__( 72 self, loco_id: str, 73 roster_path: pathlib.Path, legacy_path: pathlib.Path, 74 ) -> None: 75 super().__init__( 76 f"CV state for loco {loco_id!r} exists in both " 77 f"{roster_path} and {legacy_path}; caller must pick one via " 78 f"LocoCvStore.set_preferred_source()", 79 ) 80 self.loco_id = loco_id 81 self.roster_path = roster_path 82 self.legacy_path = legacy_path
106@dataclass 107class LocoCvStore: 108 """Lazy disk-backed map of loco-id → :class:`LocoCvState`. 109 110 ``default_lookup`` is optional; when supplied, writes filter out any 111 CV whose value matches the catalog default so ``roster/<id>.xml`` 112 stays a minimal delta. See :data:`DefaultLookup` for the callable's 113 signature. Set to ``None`` for legacy behaviour (write every CV). 114 """ 115 116 profile: Profile 117 default_lookup: DefaultLookup | None = None 118 _by_id: dict[str, LocoCvState] = field(default_factory=dict) 119 # loco_id → chosen source. Set via ``set_preferred_source`` when the 120 # UI resolves an ambiguity, or automatically when only one source 121 # has data. 122 _source_by_id: dict[str, CvSource] = field(default_factory=dict) 123 # Roster files we've already backed up during this session. 124 # Absolute paths so switching a loco's ``fileName`` still triggers 125 # a fresh backup on the new file. See :meth:`_backup_once`. 126 _backed_up_paths: set[pathlib.Path] = field(default_factory=set) 127 128 def __post_init__(self) -> None: 129 # Both locations are created lazily on first write. Not 130 # pre-scanned — the programmer opens for one loco at a time. 131 self.profile.rosters_cv_dir.mkdir(parents=True, exist_ok=True) 132 self.profile.roster_dir.mkdir(parents=True, exist_ok=True) 133 134 # ----- source resolution ------------------------------------------ 135 136 def _roster_path(self, loco_id: str, file_name: str | None) -> pathlib.Path: 137 """The JMRI-canonical file: ``<profile>/roster/<filename>``.""" 138 return self.profile.roster_dir / _resolve_filename(loco_id, file_name) 139 140 def _legacy_path(self, loco_id: str, file_name: str | None) -> pathlib.Path: 141 """Our proprietary file: ``<profile>/rosters/<filename>``.""" 142 return self.profile.rosters_cv_dir / _resolve_filename(loco_id, file_name) 143 144 def path_for( 145 self, loco_id: str, file_name: str | None = None, 146 source: CvSource | None = None, 147 ) -> pathlib.Path: 148 """Resolve the file path for *loco_id* under *source*. 149 150 Backwards-compat with older callers that assumed the legacy 151 location: when *source* is None, use the loco's preferred source 152 if known, else default to the legacy path. 153 """ 154 source = source or self._source_by_id.get(loco_id, CvSource.LEGACY) 155 if source is CvSource.ROSTER: 156 return self._roster_path(loco_id, file_name) 157 return self._legacy_path(loco_id, file_name) 158 159 def _backup_once(self, roster_path: pathlib.Path) -> None: 160 """Copy *roster_path* to ``<name>.xml.bak`` the first time the 161 session touches it. 162 163 No-op if: 164 165 * the path has already been backed up during this store's life; 166 * the source file doesn't exist (nothing to back up); 167 * the copy raises :class:`OSError` (permissions / filesystem 168 full) — logged so a follow-up sync can see the failure, but 169 the write itself proceeds. 170 """ 171 abs_path = roster_path.resolve() if roster_path.exists() else roster_path 172 if abs_path in self._backed_up_paths: 173 return 174 # Mark first so a failure to copy doesn't cause a retry storm 175 # on every subsequent write. 176 self._backed_up_paths.add(abs_path) 177 if not roster_path.exists(): 178 return 179 backup_path = roster_path.with_suffix(roster_path.suffix + ".bak") 180 try: 181 shutil.copy2(roster_path, backup_path) 182 _log.info( 183 "LocoCvStore: backed up %s → %s", roster_path, backup_path, 184 ) 185 except OSError as e: 186 _log.warning( 187 "LocoCvStore: backup %s → %s failed: %s", 188 roster_path, backup_path, e, 189 ) 190 191 def set_preferred_source( 192 self, loco_id: str, source: CvSource, 193 ) -> None: 194 """Record which source the UI wants us to use for this loco. 195 196 Clears any cached in-memory state so the next :meth:`get` reads 197 the freshly-picked source from disk. 198 """ 199 self._source_by_id[loco_id] = source 200 self._by_id.pop(loco_id, None) 201 202 # ----- read -------------------------------------------------------- 203 204 def get(self, loco_id: str, file_name: str | None = None) -> LocoCvState: 205 """Return the loco's CV state, loading from disk on first hit. 206 207 Resolution order: 208 209 1. In-memory cache 210 2. Preferred source set via :meth:`set_preferred_source` 211 3. JMRI-canonical ``roster/<id>.xml`` ``<values>`` block 212 4. Legacy ``rosters/<id>.xml`` 213 5. Empty state if none of the above have CVs 214 215 Raises :class:`AmbiguousCvSourceError` when both canonical and 216 legacy locations have CV data and the preferred source hasn't 217 been pinned yet. 218 """ 219 if loco_id in self._by_id: 220 return self._by_id[loco_id] 221 222 pref = self._source_by_id.get(loco_id) 223 if pref is CvSource.ROSTER: 224 state = self._read_roster(loco_id, file_name) or LocoCvState(loco_id=loco_id) 225 elif pref is CvSource.LEGACY: 226 state = self._read_legacy(loco_id, file_name) or LocoCvState(loco_id=loco_id) 227 else: 228 roster_state = self._read_roster(loco_id, file_name) 229 legacy_state = self._read_legacy(loco_id, file_name) 230 if roster_state and legacy_state and ( 231 roster_state.cv_values and legacy_state.cv_values 232 ): 233 raise AmbiguousCvSourceError( 234 loco_id, 235 self._roster_path(loco_id, file_name), 236 self._legacy_path(loco_id, file_name), 237 ) 238 if roster_state and roster_state.cv_values: 239 self._source_by_id[loco_id] = CvSource.ROSTER 240 state = roster_state 241 elif legacy_state: 242 self._source_by_id[loco_id] = CvSource.LEGACY 243 state = legacy_state 244 elif roster_state is not None: 245 # Roster entry file exists but has no <values> — write 246 # to JMRI-canonical on next set_cv for best interop. 247 self._source_by_id[loco_id] = CvSource.ROSTER 248 state = roster_state 249 else: 250 # Neither exists yet — default new locos to canonical. 251 self._source_by_id[loco_id] = CvSource.ROSTER 252 state = LocoCvState(loco_id=loco_id) 253 254 # File-stored ids might not match what the caller passed (JMRI 255 # ``fileName`` decouples filename from loco id). Canonicalise. 256 if state.loco_id != loco_id: 257 state = LocoCvState(loco_id=loco_id, cv_values=dict(state.cv_values)) 258 self._by_id[loco_id] = state 259 return state 260 261 def _read_roster( 262 self, loco_id: str, file_name: str | None, 263 ) -> LocoCvState | None: 264 """Try to read CV state from ``roster/<id>.xml`` (JMRI-canonical).""" 265 path = self._roster_path(loco_id, file_name) 266 if not path.exists(): 267 return None 268 try: 269 return load_cv_values_from_locomotive_config(path, loco_id=loco_id) 270 except Exception as e: # noqa: BLE001 271 _log.warning("LocoCvStore: failed to read %s: %s", path, e) 272 return None 273 274 def _read_legacy( 275 self, loco_id: str, file_name: str | None, 276 ) -> LocoCvState | None: 277 """Try to read CV state from ``rosters/<id>.xml`` (our layout).""" 278 path = self._legacy_path(loco_id, file_name) 279 if not path.exists(): 280 return None 281 try: 282 return load_loco_cv(path) 283 except Exception as e: # noqa: BLE001 284 _log.warning("LocoCvStore: failed to read %s: %s", path, e) 285 return None 286 287 # ----- write ------------------------------------------------------- 288 289 def record_read_result( 290 self, loco_id: str, cv: str, value: int, file_name: str | None = None, 291 ) -> LocoCvState: 292 """Record a freshly-read CV in the in-memory cache. Does NOT 293 persist to disk and does NOT trigger a ``.bak``. 294 295 JMRI Java treats reads as transient until the user explicitly 296 saves. Mirroring that (rather than our older persist-every-read 297 behaviour) means: 298 299 * The ``.bak`` created by :meth:`_backup_once` captures the 300 pre-modification state — a Read pane that touches 28 CVs 301 doesn't burn the backup on a read that leaves the roster 302 logically unchanged. 303 * SD cards on Pi setups don't take a write per CV read (the 304 Read pane action does ~30 reads sequentially). 305 * A restart discards the read cache — but that's exactly the 306 "reads are ephemeral" contract JMRI Java exposes. 307 308 Callers who want the read to persist (e.g. a "Save read 309 results" button, or a batch that logically commits reads) can 310 call :meth:`set_cv` with the same value. 311 """ 312 state = self.get(loco_id, file_name) 313 new_cvs = dict(state.cv_values) 314 new_cvs[str(cv)] = int(value) 315 new_state = LocoCvState(loco_id=loco_id, cv_values=new_cvs) 316 self._by_id[loco_id] = new_state 317 return new_state 318 319 def set_cv( 320 self, loco_id: str, cv: str, value: int, file_name: str | None = None, 321 ) -> LocoCvState: 322 """Set one CV value; persist to the JMRI-canonical 323 ``roster/<id>.xml`` ``<values>`` block, filtering CVs equal to 324 the catalog default. Returns the updated state. 325 326 Filtering behaviour depends on :attr:`default_lookup`: 327 328 * When supplied and it returns a non-empty dict for this loco, 329 any CV whose value matches the default is *dropped* from what 330 gets written — the roster file stays a minimal delta. 331 * When missing or empty, every in-memory CV is written (backward 332 compatible with the pre-refactor behaviour). 333 334 For interop with JMRI Java, writes always target the embedded 335 ``<values>`` block inside ``roster/<id>.xml``. The old 336 proprietary ``rosters/<id>.xml`` remains read-only for the 337 migration window; explicit ``CvSource.LEGACY`` preferences from 338 the ambiguity chooser also read from there but write forward to 339 the canonical location. 340 """ 341 state = self.get(loco_id, file_name) 342 new_cvs = dict(state.cv_values) 343 new_cvs[str(cv)] = int(value) 344 new_state = LocoCvState(loco_id=loco_id, cv_values=new_cvs) 345 self._by_id[loco_id] = new_state 346 347 # Apply the default-diff filter: drop any CV whose stringified 348 # value equals the catalog default. Comparison is done on 349 # strings so we sidestep numeric edge cases (e.g. "0"/"00"). 350 defaults: dict[str, str] = {} 351 if self.default_lookup is not None: 352 try: 353 defaults = self.default_lookup(loco_id) or {} 354 except Exception as e: # noqa: BLE001 355 _log.warning( 356 "LocoCvStore: default lookup failed for %r: %s", loco_id, e, 357 ) 358 to_persist = { 359 cv_name: cv_val for cv_name, cv_val in new_cvs.items() 360 if str(cv_val) != defaults.get(cv_name) 361 } 362 363 # Write to JMRI-canonical roster/<id>.xml <values> block. 364 roster_path = self._roster_path(loco_id, file_name) 365 # JMRI Java behaviour: back up the pristine roster file to 366 # ``<id>.xml.bak`` once, before the first write of the session. 367 # Repeated writes to the same file in the same session don't 368 # re-backup (otherwise the backup would race behind current 369 # state and lose the pre-session baseline). Backup is 370 # best-effort — a failure here logs but doesn't block the 371 # write, so a read-only ``.bak`` (e.g. permissions on an SD 372 # card) can't prevent programming. 373 self._backup_once(roster_path) 374 try: 375 save_cv_values_into_locomotive_config( 376 roster_path, to_persist, loco_id=loco_id, 377 ) 378 except OSError as e: 379 _log.warning("LocoCvStore: failed to write %s: %s", roster_path, e) 380 381 # Future reads should hit the roster path so the just-written 382 # values come back on a re-open even if legacy still has stale 383 # data from a previous alpha. 384 self._source_by_id[loco_id] = CvSource.ROSTER 385 return new_state
Lazy disk-backed map of loco-id → LocoCvState.
default_lookup is optional; when supplied, writes filter out any
CV whose value matches the catalog default so roster/<id>.xml
stays a minimal delta. See DefaultLookup for the callable's
signature. Set to None for legacy behaviour (write every CV).
144 def path_for( 145 self, loco_id: str, file_name: str | None = None, 146 source: CvSource | None = None, 147 ) -> pathlib.Path: 148 """Resolve the file path for *loco_id* under *source*. 149 150 Backwards-compat with older callers that assumed the legacy 151 location: when *source* is None, use the loco's preferred source 152 if known, else default to the legacy path. 153 """ 154 source = source or self._source_by_id.get(loco_id, CvSource.LEGACY) 155 if source is CvSource.ROSTER: 156 return self._roster_path(loco_id, file_name) 157 return self._legacy_path(loco_id, file_name)
Resolve the file path for loco_id under source.
Backwards-compat with older callers that assumed the legacy location: when source is None, use the loco's preferred source if known, else default to the legacy path.
191 def set_preferred_source( 192 self, loco_id: str, source: CvSource, 193 ) -> None: 194 """Record which source the UI wants us to use for this loco. 195 196 Clears any cached in-memory state so the next :meth:`get` reads 197 the freshly-picked source from disk. 198 """ 199 self._source_by_id[loco_id] = source 200 self._by_id.pop(loco_id, None)
Record which source the UI wants us to use for this loco.
Clears any cached in-memory state so the next get() reads
the freshly-picked source from disk.
204 def get(self, loco_id: str, file_name: str | None = None) -> LocoCvState: 205 """Return the loco's CV state, loading from disk on first hit. 206 207 Resolution order: 208 209 1. In-memory cache 210 2. Preferred source set via :meth:`set_preferred_source` 211 3. JMRI-canonical ``roster/<id>.xml`` ``<values>`` block 212 4. Legacy ``rosters/<id>.xml`` 213 5. Empty state if none of the above have CVs 214 215 Raises :class:`AmbiguousCvSourceError` when both canonical and 216 legacy locations have CV data and the preferred source hasn't 217 been pinned yet. 218 """ 219 if loco_id in self._by_id: 220 return self._by_id[loco_id] 221 222 pref = self._source_by_id.get(loco_id) 223 if pref is CvSource.ROSTER: 224 state = self._read_roster(loco_id, file_name) or LocoCvState(loco_id=loco_id) 225 elif pref is CvSource.LEGACY: 226 state = self._read_legacy(loco_id, file_name) or LocoCvState(loco_id=loco_id) 227 else: 228 roster_state = self._read_roster(loco_id, file_name) 229 legacy_state = self._read_legacy(loco_id, file_name) 230 if roster_state and legacy_state and ( 231 roster_state.cv_values and legacy_state.cv_values 232 ): 233 raise AmbiguousCvSourceError( 234 loco_id, 235 self._roster_path(loco_id, file_name), 236 self._legacy_path(loco_id, file_name), 237 ) 238 if roster_state and roster_state.cv_values: 239 self._source_by_id[loco_id] = CvSource.ROSTER 240 state = roster_state 241 elif legacy_state: 242 self._source_by_id[loco_id] = CvSource.LEGACY 243 state = legacy_state 244 elif roster_state is not None: 245 # Roster entry file exists but has no <values> — write 246 # to JMRI-canonical on next set_cv for best interop. 247 self._source_by_id[loco_id] = CvSource.ROSTER 248 state = roster_state 249 else: 250 # Neither exists yet — default new locos to canonical. 251 self._source_by_id[loco_id] = CvSource.ROSTER 252 state = LocoCvState(loco_id=loco_id) 253 254 # File-stored ids might not match what the caller passed (JMRI 255 # ``fileName`` decouples filename from loco id). Canonicalise. 256 if state.loco_id != loco_id: 257 state = LocoCvState(loco_id=loco_id, cv_values=dict(state.cv_values)) 258 self._by_id[loco_id] = state 259 return state
Return the loco's CV state, loading from disk on first hit.
Resolution order:
- In-memory cache
- Preferred source set via
set_preferred_source() - JMRI-canonical
roster/<id>.xml<values>block - Legacy
rosters/<id>.xml - Empty state if none of the above have CVs
Raises AmbiguousCvSourceError when both canonical and
legacy locations have CV data and the preferred source hasn't
been pinned yet.
289 def record_read_result( 290 self, loco_id: str, cv: str, value: int, file_name: str | None = None, 291 ) -> LocoCvState: 292 """Record a freshly-read CV in the in-memory cache. Does NOT 293 persist to disk and does NOT trigger a ``.bak``. 294 295 JMRI Java treats reads as transient until the user explicitly 296 saves. Mirroring that (rather than our older persist-every-read 297 behaviour) means: 298 299 * The ``.bak`` created by :meth:`_backup_once` captures the 300 pre-modification state — a Read pane that touches 28 CVs 301 doesn't burn the backup on a read that leaves the roster 302 logically unchanged. 303 * SD cards on Pi setups don't take a write per CV read (the 304 Read pane action does ~30 reads sequentially). 305 * A restart discards the read cache — but that's exactly the 306 "reads are ephemeral" contract JMRI Java exposes. 307 308 Callers who want the read to persist (e.g. a "Save read 309 results" button, or a batch that logically commits reads) can 310 call :meth:`set_cv` with the same value. 311 """ 312 state = self.get(loco_id, file_name) 313 new_cvs = dict(state.cv_values) 314 new_cvs[str(cv)] = int(value) 315 new_state = LocoCvState(loco_id=loco_id, cv_values=new_cvs) 316 self._by_id[loco_id] = new_state 317 return new_state
Record a freshly-read CV in the in-memory cache. Does NOT
persist to disk and does NOT trigger a .bak.
JMRI Java treats reads as transient until the user explicitly saves. Mirroring that (rather than our older persist-every-read behaviour) means:
- The
.bakcreated by_backup_once()captures the pre-modification state — a Read pane that touches 28 CVs doesn't burn the backup on a read that leaves the roster logically unchanged. - SD cards on Pi setups don't take a write per CV read (the Read pane action does ~30 reads sequentially).
- A restart discards the read cache — but that's exactly the "reads are ephemeral" contract JMRI Java exposes.
Callers who want the read to persist (e.g. a "Save read
results" button, or a batch that logically commits reads) can
call set_cv() with the same value.
319 def set_cv( 320 self, loco_id: str, cv: str, value: int, file_name: str | None = None, 321 ) -> LocoCvState: 322 """Set one CV value; persist to the JMRI-canonical 323 ``roster/<id>.xml`` ``<values>`` block, filtering CVs equal to 324 the catalog default. Returns the updated state. 325 326 Filtering behaviour depends on :attr:`default_lookup`: 327 328 * When supplied and it returns a non-empty dict for this loco, 329 any CV whose value matches the default is *dropped* from what 330 gets written — the roster file stays a minimal delta. 331 * When missing or empty, every in-memory CV is written (backward 332 compatible with the pre-refactor behaviour). 333 334 For interop with JMRI Java, writes always target the embedded 335 ``<values>`` block inside ``roster/<id>.xml``. The old 336 proprietary ``rosters/<id>.xml`` remains read-only for the 337 migration window; explicit ``CvSource.LEGACY`` preferences from 338 the ambiguity chooser also read from there but write forward to 339 the canonical location. 340 """ 341 state = self.get(loco_id, file_name) 342 new_cvs = dict(state.cv_values) 343 new_cvs[str(cv)] = int(value) 344 new_state = LocoCvState(loco_id=loco_id, cv_values=new_cvs) 345 self._by_id[loco_id] = new_state 346 347 # Apply the default-diff filter: drop any CV whose stringified 348 # value equals the catalog default. Comparison is done on 349 # strings so we sidestep numeric edge cases (e.g. "0"/"00"). 350 defaults: dict[str, str] = {} 351 if self.default_lookup is not None: 352 try: 353 defaults = self.default_lookup(loco_id) or {} 354 except Exception as e: # noqa: BLE001 355 _log.warning( 356 "LocoCvStore: default lookup failed for %r: %s", loco_id, e, 357 ) 358 to_persist = { 359 cv_name: cv_val for cv_name, cv_val in new_cvs.items() 360 if str(cv_val) != defaults.get(cv_name) 361 } 362 363 # Write to JMRI-canonical roster/<id>.xml <values> block. 364 roster_path = self._roster_path(loco_id, file_name) 365 # JMRI Java behaviour: back up the pristine roster file to 366 # ``<id>.xml.bak`` once, before the first write of the session. 367 # Repeated writes to the same file in the same session don't 368 # re-backup (otherwise the backup would race behind current 369 # state and lose the pre-session baseline). Backup is 370 # best-effort — a failure here logs but doesn't block the 371 # write, so a read-only ``.bak`` (e.g. permissions on an SD 372 # card) can't prevent programming. 373 self._backup_once(roster_path) 374 try: 375 save_cv_values_into_locomotive_config( 376 roster_path, to_persist, loco_id=loco_id, 377 ) 378 except OSError as e: 379 _log.warning("LocoCvStore: failed to write %s: %s", roster_path, e) 380 381 # Future reads should hit the roster path so the just-written 382 # values come back on a re-open even if legacy still has stale 383 # data from a previous alpha. 384 self._source_by_id[loco_id] = CvSource.ROSTER 385 return new_state
Set one CV value; persist to the JMRI-canonical
roster/<id>.xml <values> block, filtering CVs equal to
the catalog default. Returns the updated state.
Filtering behaviour depends on default_lookup:
- When supplied and it returns a non-empty dict for this loco, any CV whose value matches the default is dropped from what gets written — the roster file stays a minimal delta.
- When missing or empty, every in-memory CV is written (backward compatible with the pre-refactor behaviour).
For interop with JMRI Java, writes always target the embedded
<values> block inside roster/<id>.xml. The old
proprietary rosters/<id>.xml remains read-only for the
migration window; explicit CvSource.LEGACY preferences from
the ambiguity chooser also read from there but write forward to
the canonical location.