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>.xml with 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
DefaultLookup = collections.abc.Callable[[str], dict[str, str]]
class CvSource(enum.StrEnum):
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.

ROSTER = <CvSource.ROSTER: 'roster'>
LEGACY = <CvSource.LEGACY: 'legacy'>
class AmbiguousCvSourceError(builtins.Exception):
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.

AmbiguousCvSourceError( loco_id: str, roster_path: pathlib._local.Path, legacy_path: pathlib._local.Path)
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
loco_id
roster_path
legacy_path
@dataclass
class LocoCvStore:
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).

LocoCvStore( profile: jmri_core.persistence.Profile, default_lookup: Callable[[str], dict[str, str]] | None = None, _by_id: dict[str, jmri_core.beans.loco_cv_state.LocoCvState] = <factory>, _source_by_id: dict[str, CvSource] = <factory>, _backed_up_paths: set[pathlib._local.Path] = <factory>)
default_lookup: Callable[[str], dict[str, str]] | None = None
def path_for( self, loco_id: str, file_name: str | None = None, source: CvSource | None = None) -> pathlib._local.Path:
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.

def set_preferred_source(self, loco_id: str, source: CvSource) -> None:
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.

def get( self, loco_id: str, file_name: str | None = None) -> jmri_core.beans.loco_cv_state.LocoCvState:
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:

  1. In-memory cache
  2. Preferred source set via set_preferred_source()
  3. JMRI-canonical roster/<id>.xml <values> block
  4. Legacy rosters/<id>.xml
  5. 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.

def record_read_result( self, loco_id: str, cv: str, value: int, file_name: str | None = None) -> jmri_core.beans.loco_cv_state.LocoCvState:
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 .bak created 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.

def set_cv( self, loco_id: str, cv: str, value: int, file_name: str | None = None) -> jmri_core.beans.loco_cv_state.LocoCvState:
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.