jmri_server.services.programmer

ProgrammerService — JSON wire interface for CV programming.

JMRI's stock JSON API doesn't expose decoder programming, so we ship a small dedicated topic at type: "programmer". Wire shapes:

  • method=get, data={"cv": N, "mode": "service"|"ops", "address"?: N} → reads the CV, returns {"cv": N, "value": 0..255}.
  • method=post, data={"cv": N, "value": V, ...} → writes the CV.
  • method=list, data={"mode": "service"|"ops"} → returns {"mode": ..., "available": true|false, "ops_address": ...}.

The service uses the active connection's ProgrammerManager. If the connection has no programmer (default in-memory connections), every request returns available: false.

  1"""``ProgrammerService`` — JSON wire interface for CV programming.
  2
  3JMRI's stock JSON API doesn't expose decoder programming, so we ship a
  4small dedicated topic at ``type: "programmer"``. Wire shapes:
  5
  6* ``method=get``, ``data={"cv": N, "mode": "service"|"ops", "address"?: N}``
  7  → reads the CV, returns ``{"cv": N, "value": 0..255}``.
  8* ``method=post``, ``data={"cv": N, "value": V, ...}`` → writes the CV.
  9* ``method=list``, ``data={"mode": "service"|"ops"}`` → returns
 10  ``{"mode": ..., "available": true|false, "ops_address": ...}``.
 11
 12The service uses the active connection's ``ProgrammerManager``. If the
 13connection has no programmer (default in-memory connections), every
 14request returns ``available: false``.
 15"""
 16from __future__ import annotations
 17
 18from typing import Any, cast
 19
 20from jmri_core.beans.programmer import Programmer, ProgrammingMode
 21
 22from jmri_server.service import JsonService, JsonServiceError
 23
 24# CV value range. JMRI ships CVs in the standard NMRA 8-bit range.
 25CV_VALUE_MIN = 0
 26CV_VALUE_MAX = 255
 27
 28
 29def _resolve_programmer(ctx_connection: Any, data: dict[str, Any]) -> Programmer:
 30    """Pick a service or ops-mode programmer from the request data."""
 31    mode_str = data.get("mode", "service")
 32    if mode_str == "service":
 33        programmer = ctx_connection.programmers.global_programmer()
 34        if programmer is None:
 35            raise JsonServiceError(
 36                503,
 37                f"connection {ctx_connection.name!r} has no service-mode programmer",
 38            )
 39        return cast("Programmer", programmer)
 40    if mode_str == "ops":
 41        address = data.get("address")
 42        if not isinstance(address, int):
 43            raise JsonServiceError(400, "ops-mode programming requires an 'address'")
 44        long_address = bool(data.get("isLongAddress", False))
 45        programmer = ctx_connection.programmers.ops_programmer(
 46            address, long_address=long_address
 47        )
 48        if programmer is None:
 49            raise JsonServiceError(
 50                503,
 51                f"connection {ctx_connection.name!r} has no ops-mode programmer",
 52            )
 53        return cast("Programmer", programmer)
 54    raise JsonServiceError(400, f"unknown programmer mode {mode_str!r}")
 55
 56
 57def _loco_cv_store(ctx: Any) -> Any:
 58    """Lazily attach a :class:`LocoCvStore` rooted at the active profile.
 59
 60    Wires a ``default_lookup`` callable that maps ``loco_id`` →
 61    ``{cv_number: default_str}`` by consulting the roster (for the loco's
 62    family/model) + the decoder catalog (for that model's variable
 63    defaults). When the catalog isn't loaded or the loco has no decoder
 64    metadata, returns ``{}`` so :meth:`LocoCvStore.set_cv` writes every
 65    CV (equivalent to pre-refactor behaviour).
 66    """
 67    store = getattr(ctx, "_loco_cv_store", None)
 68    if store is not None:
 69        return store
 70    from jmri_core.persistence.profile import Profile  # noqa: PLC0415
 71
 72    from jmri_server.loco_cv_store import LocoCvStore  # noqa: PLC0415
 73    profile = getattr(ctx, "_roster_profile", None) or Profile.default()
 74
 75    def _defaults_for(loco_id: str) -> dict[str, str]:
 76        """Look up catalog defaults for *loco_id*'s decoder model.
 77
 78        Failure modes (missing catalog, unknown model, no roster entry)
 79        all resolve to an empty dict so the write path just skips the
 80        filter — losing the "diff-only" optimisation but preserving
 81        correctness (every CV in memory gets written).
 82        """
 83        catalog = getattr(ctx, "_decoder_catalog", None)
 84        if catalog is None:
 85            return {}
 86        # Resolve family/model via the roster service. This runs
 87        # synchronously inside LocoCvStore.set_cv; the roster service's
 88        # own read path is sync-safe.
 89        try:
 90            from jmri_server.services.roster import (  # noqa: PLC0415
 91                _roster_service_get_sync,
 92            )
 93            entry = _roster_service_get_sync(ctx, loco_id)
 94        except Exception:  # noqa: BLE001
 95            return {}
 96        family = (entry or {}).get("decoderFamily") or ""
 97        model = (entry or {}).get("decoderModel") or ""
 98        if not family or not model:
 99            return {}
100        defn = catalog.find(family, model)
101        if defn is None:
102            return {}
103        # Compose per-CV *default bytes* by applying each variable's
104        # human-default value through ``variable_apply_to_state``. Bit-
105        # mask variables sharing a CV (Address Format + Speed Steps +
106        # Power Source Conv on CV 29) each fold their bits into the
107        # same byte via read-modify-write against the running snapshot.
108        # Split-CV variables (long address = CV 17 + CV 18) produce
109        # entries for every touched CV. Skip variable kinds that don't
110        # participate in byte-mask composition (SPEED_TABLE, COMPOSITE,
111        # SPLIT_DATETIME, CONSTANT).
112        from jmri_core.persistence.decoder_xml import (  # noqa: PLC0415
113            VariableKind,
114            variable_apply_to_state,
115            variable_is_indexed,
116            variable_store_key,
117        )
118        # String-keyed so plain and indexed CV defaults coexist
119        # (``275`` vs ``16.1.275``). Indexed variables get their
120        # default bytes stored per compound key; plain variables get
121        # str(cv) as before.
122        result: dict[str, str] = {}
123        int_view: dict[int, int] = {}
124        for var in defn.variables:
125            if var.default is None:
126                continue
127            if var.kind in (
128                VariableKind.SPEED_TABLE,
129                VariableKind.COMPOSITE,
130                VariableKind.SPLIT_DATETIME,
131                VariableKind.CONSTANT,
132            ):
133                continue
134            if variable_is_indexed(var):
135                # Indexed: emit one entry per byte under compound key.
136                assert var.split is not None
137                base = var.split.primary_cv
138                for i in range(var.split.byte_count):
139                    key = variable_store_key(var, base + i)
140                    shift = 8 * (var.split.byte_count - 1 - i)
141                    result[key] = str((int(var.default) >> shift) & 0xFF)
142                continue
143            try:
144                updates = variable_apply_to_state(var, var.default, int_view)
145            except Exception:  # noqa: BLE001
146                continue
147            int_view.update(updates)
148            for cv_int, byte in updates.items():
149                result[str(cv_int)] = str(byte)
150        return result
151
152    store = LocoCvStore(profile=profile, default_lookup=_defaults_for)
153    ctx._loco_cv_store = store
154    return store
155
156
157class ProgrammerService(JsonService):
158    topic = "programmer"
159
160    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
161        data = data or {}
162        cv = data.get("cv")
163        if not isinstance(cv, int):
164            raise JsonServiceError(400, "programmer.get requires integer 'cv'")
165        programmer = _resolve_programmer(self.ctx.connection, data)
166        try:
167            value = await programmer.read_cv(cv)
168        except TimeoutError:
169            raise JsonServiceError(504, f"timeout reading CV {cv}") from None
170        except NotImplementedError as e:
171            # e.g. NCE ops-mode CV read — no RailCom on PowerPro.
172            raise JsonServiceError(501, str(e)) from None
173        except Exception as e:  # noqa: BLE001
174            # Connection-specific errors (NceCommandError, LocoNet CS
175            # NACKs, DCC-EX parse failures). Surface as 502 so the UI
176            # can distinguish from timeouts/protocol errors upstream.
177            raise JsonServiceError(502, f"CV {cv} read failed: {e}") from None
178        # Record the freshly-read value in the in-memory cache so the
179        # next pane render (and subsequent variable_extract_from_state
180        # calls) sees it. JMRI Java treats reads as transient — no
181        # disk persistence, no ``.bak`` — until the user explicitly
182        # saves. We mirror that via ``record_read_result`` rather than
183        # ``set_cv``. A Read pane over 28 CVs used to translate into
184        # 28 atomic disk writes (rough on SD cards); now it's zero.
185        loco_id = data.get("locoId") or data.get("loco_id")
186        if isinstance(loco_id, str) and loco_id:
187            file_name = data.get("fileName") or data.get("file_name")
188            if not isinstance(file_name, str):
189                file_name = None
190            _loco_cv_store(self.ctx).record_read_result(
191                loco_id, str(cv), value, file_name,
192            )
193        return {"cv": cv, "value": value, "mode": programmer.mode.value}
194
195    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
196        data = data or {}
197        cv = data.get("cv")
198        value = data.get("value")
199        if not isinstance(cv, int):
200            raise JsonServiceError(400, "programmer.post requires integer 'cv'")
201        if not isinstance(value, int) or not CV_VALUE_MIN <= value <= CV_VALUE_MAX:
202            raise JsonServiceError(
203                400,
204                f"programmer.post requires integer 'value' {CV_VALUE_MIN}..{CV_VALUE_MAX}",
205            )
206        programmer = _resolve_programmer(self.ctx.connection, data)
207        try:
208            await programmer.write_cv(cv, value)
209        except TimeoutError:
210            raise JsonServiceError(504, f"timeout writing CV {cv}") from None
211        except NotImplementedError as e:
212            raise JsonServiceError(501, str(e)) from None
213        except Exception as e:  # noqa: BLE001
214            raise JsonServiceError(502, f"CV {cv} write failed: {e}") from None
215        # Persist the committed write to the per-loco CV file if a loco
216        # was identified — keeps the on-disk state in sync with what
217        # was actually programmed. JMRI's roster ``fileName`` attribute
218        # decouples loco-id from filename (e.g. id=459 fileName=1.xml).
219        loco_id = data.get("locoId") or data.get("loco_id")
220        if isinstance(loco_id, str) and loco_id:
221            file_name = data.get("fileName") or data.get("file_name")
222            if not isinstance(file_name, str):
223                file_name = None
224            _loco_cv_store(self.ctx).set_cv(loco_id, str(cv), value, file_name)
225        return {"cv": cv, "value": value, "mode": programmer.mode.value}
226
227    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
228        """Report what programmers are available on the active connection."""
229        out: list[dict[str, Any]] = []
230        global_prog = self.ctx.connection.programmers.global_programmer()
231        out.append({
232            "mode": ProgrammingMode.SERVICE_DIRECT.value,
233            "available": global_prog is not None,
234        })
235        # Ops-mode availability per-address; we report just the manager-level capability.
236        try:
237            ops_capable = (
238                self.ctx.connection.programmers.ops_programmer(3) is not None
239            )
240        except Exception:  # noqa: BLE001
241            ops_capable = False
242        out.append({
243            "mode": ProgrammingMode.OPS_MODE.value,
244            "available": ops_capable,
245        })
246        return out
CV_VALUE_MIN = 0
CV_VALUE_MAX = 255
class ProgrammerService(jmri_server.service.JsonService):
158class ProgrammerService(JsonService):
159    topic = "programmer"
160
161    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
162        data = data or {}
163        cv = data.get("cv")
164        if not isinstance(cv, int):
165            raise JsonServiceError(400, "programmer.get requires integer 'cv'")
166        programmer = _resolve_programmer(self.ctx.connection, data)
167        try:
168            value = await programmer.read_cv(cv)
169        except TimeoutError:
170            raise JsonServiceError(504, f"timeout reading CV {cv}") from None
171        except NotImplementedError as e:
172            # e.g. NCE ops-mode CV read — no RailCom on PowerPro.
173            raise JsonServiceError(501, str(e)) from None
174        except Exception as e:  # noqa: BLE001
175            # Connection-specific errors (NceCommandError, LocoNet CS
176            # NACKs, DCC-EX parse failures). Surface as 502 so the UI
177            # can distinguish from timeouts/protocol errors upstream.
178            raise JsonServiceError(502, f"CV {cv} read failed: {e}") from None
179        # Record the freshly-read value in the in-memory cache so the
180        # next pane render (and subsequent variable_extract_from_state
181        # calls) sees it. JMRI Java treats reads as transient — no
182        # disk persistence, no ``.bak`` — until the user explicitly
183        # saves. We mirror that via ``record_read_result`` rather than
184        # ``set_cv``. A Read pane over 28 CVs used to translate into
185        # 28 atomic disk writes (rough on SD cards); now it's zero.
186        loco_id = data.get("locoId") or data.get("loco_id")
187        if isinstance(loco_id, str) and loco_id:
188            file_name = data.get("fileName") or data.get("file_name")
189            if not isinstance(file_name, str):
190                file_name = None
191            _loco_cv_store(self.ctx).record_read_result(
192                loco_id, str(cv), value, file_name,
193            )
194        return {"cv": cv, "value": value, "mode": programmer.mode.value}
195
196    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
197        data = data or {}
198        cv = data.get("cv")
199        value = data.get("value")
200        if not isinstance(cv, int):
201            raise JsonServiceError(400, "programmer.post requires integer 'cv'")
202        if not isinstance(value, int) or not CV_VALUE_MIN <= value <= CV_VALUE_MAX:
203            raise JsonServiceError(
204                400,
205                f"programmer.post requires integer 'value' {CV_VALUE_MIN}..{CV_VALUE_MAX}",
206            )
207        programmer = _resolve_programmer(self.ctx.connection, data)
208        try:
209            await programmer.write_cv(cv, value)
210        except TimeoutError:
211            raise JsonServiceError(504, f"timeout writing CV {cv}") from None
212        except NotImplementedError as e:
213            raise JsonServiceError(501, str(e)) from None
214        except Exception as e:  # noqa: BLE001
215            raise JsonServiceError(502, f"CV {cv} write failed: {e}") from None
216        # Persist the committed write to the per-loco CV file if a loco
217        # was identified — keeps the on-disk state in sync with what
218        # was actually programmed. JMRI's roster ``fileName`` attribute
219        # decouples loco-id from filename (e.g. id=459 fileName=1.xml).
220        loco_id = data.get("locoId") or data.get("loco_id")
221        if isinstance(loco_id, str) and loco_id:
222            file_name = data.get("fileName") or data.get("file_name")
223            if not isinstance(file_name, str):
224                file_name = None
225            _loco_cv_store(self.ctx).set_cv(loco_id, str(cv), value, file_name)
226        return {"cv": cv, "value": value, "mode": programmer.mode.value}
227
228    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
229        """Report what programmers are available on the active connection."""
230        out: list[dict[str, Any]] = []
231        global_prog = self.ctx.connection.programmers.global_programmer()
232        out.append({
233            "mode": ProgrammingMode.SERVICE_DIRECT.value,
234            "available": global_prog is not None,
235        })
236        # Ops-mode availability per-address; we report just the manager-level capability.
237        try:
238            ops_capable = (
239                self.ctx.connection.programmers.ops_programmer(3) is not None
240            )
241        except Exception:  # noqa: BLE001
242            ops_capable = False
243        out.append({
244            "mode": ProgrammingMode.OPS_MODE.value,
245            "available": ops_capable,
246        })
247        return out

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'programmer'
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
161    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
162        data = data or {}
163        cv = data.get("cv")
164        if not isinstance(cv, int):
165            raise JsonServiceError(400, "programmer.get requires integer 'cv'")
166        programmer = _resolve_programmer(self.ctx.connection, data)
167        try:
168            value = await programmer.read_cv(cv)
169        except TimeoutError:
170            raise JsonServiceError(504, f"timeout reading CV {cv}") from None
171        except NotImplementedError as e:
172            # e.g. NCE ops-mode CV read — no RailCom on PowerPro.
173            raise JsonServiceError(501, str(e)) from None
174        except Exception as e:  # noqa: BLE001
175            # Connection-specific errors (NceCommandError, LocoNet CS
176            # NACKs, DCC-EX parse failures). Surface as 502 so the UI
177            # can distinguish from timeouts/protocol errors upstream.
178            raise JsonServiceError(502, f"CV {cv} read failed: {e}") from None
179        # Record the freshly-read value in the in-memory cache so the
180        # next pane render (and subsequent variable_extract_from_state
181        # calls) sees it. JMRI Java treats reads as transient — no
182        # disk persistence, no ``.bak`` — until the user explicitly
183        # saves. We mirror that via ``record_read_result`` rather than
184        # ``set_cv``. A Read pane over 28 CVs used to translate into
185        # 28 atomic disk writes (rough on SD cards); now it's zero.
186        loco_id = data.get("locoId") or data.get("loco_id")
187        if isinstance(loco_id, str) and loco_id:
188            file_name = data.get("fileName") or data.get("file_name")
189            if not isinstance(file_name, str):
190                file_name = None
191            _loco_cv_store(self.ctx).record_read_result(
192                loco_id, str(cv), value, file_name,
193            )
194        return {"cv": cv, "value": value, "mode": programmer.mode.value}
async def on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
196    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
197        data = data or {}
198        cv = data.get("cv")
199        value = data.get("value")
200        if not isinstance(cv, int):
201            raise JsonServiceError(400, "programmer.post requires integer 'cv'")
202        if not isinstance(value, int) or not CV_VALUE_MIN <= value <= CV_VALUE_MAX:
203            raise JsonServiceError(
204                400,
205                f"programmer.post requires integer 'value' {CV_VALUE_MIN}..{CV_VALUE_MAX}",
206            )
207        programmer = _resolve_programmer(self.ctx.connection, data)
208        try:
209            await programmer.write_cv(cv, value)
210        except TimeoutError:
211            raise JsonServiceError(504, f"timeout writing CV {cv}") from None
212        except NotImplementedError as e:
213            raise JsonServiceError(501, str(e)) from None
214        except Exception as e:  # noqa: BLE001
215            raise JsonServiceError(502, f"CV {cv} write failed: {e}") from None
216        # Persist the committed write to the per-loco CV file if a loco
217        # was identified — keeps the on-disk state in sync with what
218        # was actually programmed. JMRI's roster ``fileName`` attribute
219        # decouples loco-id from filename (e.g. id=459 fileName=1.xml).
220        loco_id = data.get("locoId") or data.get("loco_id")
221        if isinstance(loco_id, str) and loco_id:
222            file_name = data.get("fileName") or data.get("file_name")
223            if not isinstance(file_name, str):
224                file_name = None
225            _loco_cv_store(self.ctx).set_cv(loco_id, str(cv), value, file_name)
226        return {"cv": cv, "value": value, "mode": programmer.mode.value}
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
228    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
229        """Report what programmers are available on the active connection."""
230        out: list[dict[str, Any]] = []
231        global_prog = self.ctx.connection.programmers.global_programmer()
232        out.append({
233            "mode": ProgrammingMode.SERVICE_DIRECT.value,
234            "available": global_prog is not None,
235        })
236        # Ops-mode availability per-address; we report just the manager-level capability.
237        try:
238            ops_capable = (
239                self.ctx.connection.programmers.ops_programmer(3) is not None
240            )
241        except Exception:  # noqa: BLE001
242            ops_capable = False
243        out.append({
244            "mode": ProgrammingMode.OPS_MODE.value,
245            "available": ops_capable,
246        })
247        return out

Report what programmers are available on the active connection.