jmri_server.services.consist

ConsistService — JSON wire interface for the consist manager.

Wire shape (a jmri-server-specific extension; stock JMRI Java's JSON API exposes consist too, but with a different envelope — this service uses the same envelope style as throttle and scripts so all rewrite-specific services are consistent).

Methods:

  • list → array of projected consist dicts.
  • get, data={"address": N} → one projected dict.
  • put, data={"address": N, "long_address": bool, "kind": "software"|"command_station"|"advanced", "comment": "..."} → create-if-absent and return the projected dict. Idempotent.
  • post, data={"address": N, "action": ..., ...} — mutate the consist. Verbs:
    • add_member — member = {address, long_address, direction_normal}.
    • remove_member — member_address = int.
    • set_speed — speed = float in [-1.0, 1.0].
    • set_direction — direction = "forward"/"reverse".
    • set_function — function = int 0..28, on = bool.
    • emergency_stop — no extra fields.
  • delete, data={"address": N} → drop the consist.

Projected consist shape::

{
  "address": 3601,
  "long_address": true,
  "kind": "command_station",
  "comment": "Baldwin twins",
  "locos": [
    {"address": 3601, "long_address": true,
     "direction_normal": true, "position": 0},
    {"address": 3602, "long_address": true,
     "direction_normal": false, "position": 1}
  ]
}
  1"""``ConsistService`` — JSON wire interface for the consist manager.
  2
  3Wire shape (a jmri-server-specific extension; stock JMRI Java's JSON
  4API exposes ``consist`` too, but with a different envelope — this
  5service uses the same envelope style as ``throttle`` and ``scripts``
  6so all rewrite-specific services are consistent).
  7
  8Methods:
  9
 10* ``list`` → array of projected consist dicts.
 11* ``get``, ``data={"address": N}`` → one projected dict.
 12* ``put``, ``data={"address": N, "long_address": bool, "kind":
 13  "software"|"command_station"|"advanced", "comment": "..."}`` →
 14  create-if-absent and return the projected dict. Idempotent.
 15* ``post``, ``data={"address": N, "action": ..., ...}`` — mutate the
 16  consist. Verbs:
 17    - ``add_member`` — ``member`` = ``{address, long_address,
 18      direction_normal}``.
 19    - ``remove_member`` — ``member_address`` = int.
 20    - ``set_speed`` — ``speed`` = float in [-1.0, 1.0].
 21    - ``set_direction`` — ``direction`` = ``"forward"``/``"reverse"``.
 22    - ``set_function`` — ``function`` = int 0..28, ``on`` = bool.
 23    - ``emergency_stop`` — no extra fields.
 24* ``delete``, ``data={"address": N}`` → drop the consist.
 25
 26Projected consist shape::
 27
 28    {
 29      "address": 3601,
 30      "long_address": true,
 31      "kind": "command_station",
 32      "comment": "Baldwin twins",
 33      "locos": [
 34        {"address": 3601, "long_address": true,
 35         "direction_normal": true, "position": 0},
 36        {"address": 3602, "long_address": true,
 37         "direction_normal": false, "position": 1}
 38      ]
 39    }
 40"""
 41from __future__ import annotations
 42
 43from typing import Any
 44
 45from jmri_core.beans.consist import CONSIST_ADDRESS_MAX, Consist
 46from jmri_core.beans.consist_manager import ConsistManager
 47from jmri_core.state import ThrottleDirection
 48
 49from jmri_server.service import JsonService, JsonServiceError
 50
 51
 52def _project(c: Consist, mgr: ConsistManager | None = None) -> dict[str, Any]:
 53    """Project a consist to its wire form.
 54
 55    ``mgr`` is optional so callers that only have a :class:`Consist` in
 56    hand (mostly tests and older code paths) can still project — the
 57    speed/direction fields fall back to defaults in that case. The
 58    push wiring and the JSON service pass the manager so the projection
 59    carries the runtime drive state the UI needs to render the slider
 60    at the right position and highlight the active direction button.
 61    """
 62    projection: dict[str, Any] = {
 63        "address": c.address,
 64        "kind": c.kind,
 65        "comment": c.comment,
 66        "locos": [
 67            {
 68                "address": m.address,
 69                "long_address": m.long_address,
 70                "direction_normal": m.direction_normal,
 71                "position": m.position,
 72            }
 73            for m in c.locos
 74        ],
 75    }
 76    if mgr is not None:
 77        projection["speed"] = mgr.current_speed(c.address)
 78        projection["direction"] = (
 79            "forward"
 80            if mgr.current_direction(c.address) is ThrottleDirection.FORWARD
 81            else "reverse"
 82        )
 83    else:
 84        projection["speed"] = 0.0
 85        projection["direction"] = "forward"
 86    return projection
 87
 88
 89def _address_from(data: dict[str, Any]) -> int:
 90    """Extract the consist address from a request. Raises 400 on missing."""
 91    addr = data.get("address")
 92    if not isinstance(addr, int):
 93        raise JsonServiceError(400, "consist request requires int 'address'")
 94    return addr
 95
 96
 97class ConsistService(JsonService):
 98    topic = "consist"
 99
100    @property
101    def _consists(self) -> ConsistManager:
102        return self.ctx.connection.consists
103
104    def _require(self, address: int) -> Consist:
105        c = self._consists.get(address)
106        if c is None:
107            raise JsonServiceError(404, f"no consist at address {address}")
108        return c
109
110    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
111        return [_project(c, self._consists) for c in self._consists]
112
113    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
114        addr = _address_from(data or {})
115        return _project(self._require(addr), self._consists)
116
117    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
118        """Create-if-absent. Idempotent: returns the existing consist if
119        one already lives at ``address`` without overwriting its
120        members. Change comment/kind on an existing consist via a
121        follow-up ``post`` (not currently wired — the two fields are
122        immutable after create; a delete+recreate is the escape hatch).
123
124        Enforces two rules that were left implicit before:
125
126        * Consist address must be 1..127 (short only). CV19 is 7-bit,
127          so anything else is either meaningless (advanced) or
128          gratuitously inconsistent across kinds.
129        * Address must not collide with any existing roster loco. If
130          it did, the roster loco would silently participate in every
131          fan-out addressed to that consist — a footgun.
132        """
133        data = data or {}
134        addr = _address_from(data)
135        if not 1 <= addr <= CONSIST_ADDRESS_MAX:
136            raise JsonServiceError(
137                400,
138                f"consist address must be 1..{CONSIST_ADDRESS_MAX} short, "
139                f"got {addr}",
140            )
141        kind = data.get("kind")
142        comment = str(data.get("comment", ""))
143        # Validate kind against the model's Literal — raise 400 on typo.
144        if kind is not None and kind not in ("software", "command_station", "advanced"):
145            raise JsonServiceError(400, f"invalid consist kind: {kind!r}")
146        # Roster-collision check. Skip if the consist already exists
147        # at this address (idempotent create still wins).
148        if self._consists.get(addr) is None:
149            conflict = self._roster_conflict(addr)
150            if conflict is not None:
151                raise JsonServiceError(
152                    409,
153                    f"consist address {addr} collides with roster loco "
154                    f"{conflict!r} (short {addr}); pick a different "
155                    f"consist address or remove/renumber the loco.",
156                )
157        consist = await self._consists.create(addr, kind=kind, comment=comment)
158        return _project(consist, self._consists)
159
160    def _roster_conflict(self, addr: int) -> str | None:
161        """Return the id of any roster entry using *addr* as a short
162        DCC address, or ``None`` if there's no collision.
163
164        Only short addresses count — a long-address loco at the same
165        numeric value shares no wire space with a 1..127 consist. The
166        roster store is lazy-loaded here on first use (same pattern
167        as :class:`RosterService`) so the check works even when the
168        user's first action is Create-consist and no roster/throttle
169        page has been hit yet — that was a real bug where the check
170        silently short-circuited when navigation started on
171        ``/consists``.
172        """
173        store = self._roster_store()
174        if store is None:
175            return None
176        target = str(addr)
177        for entry in store:
178            if entry.long_address:
179                continue
180            if entry.dcc_address == target:
181                return entry.id
182        return None
183
184    def _roster_store(self) -> Any:
185        """Return the shared :class:`RosterStore`, constructing it on
186        first access if nothing else has touched it yet.
187
188        Uses the same context attribute (``_roster_store``) as
189        :class:`RosterService` so both see the same instance — no
190        double-load, and edits made through /roster show up in this
191        check immediately.
192        """
193        store = getattr(self.ctx, "_roster_store", None)
194        if store is not None:
195            return store
196        try:
197            from jmri_core.persistence.profile import Profile  # noqa: PLC0415
198            from jmri_server.roster_store import RosterStore  # noqa: PLC0415
199        except ImportError:
200            return None
201        profile = getattr(self.ctx, "_roster_profile", None) or Profile.default()
202        try:
203            store = RosterStore(profile=profile)
204        except Exception:  # noqa: BLE001
205            # No profile on disk, permission error, corrupt index — the
206            # conflict check is a courtesy, not a hard invariant. Bail
207            # rather than fail create.
208            return None
209        self.ctx._roster_store = store  # type: ignore[attr-defined]
210        return store
211
212    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
213        data = data or {}
214        addr = _address_from(data)
215        action = data.get("action")
216        # Every action targets an existing consist.
217        self._require(addr)
218        if action == "add_member":
219            m = data.get("member") or {}
220            member_addr = m.get("address")
221            if not isinstance(member_addr, int):
222                raise JsonServiceError(
223                    400, "consist.add_member requires 'member.address'",
224                )
225            await self._consists.add_member(
226                addr, member_addr,
227                long_address=bool(m.get("long_address", False)),
228                direction_normal=bool(m.get("direction_normal", True)),
229            )
230        elif action == "remove_member":
231            m_addr = data.get("member_address")
232            if not isinstance(m_addr, int):
233                raise JsonServiceError(
234                    400, "consist.remove_member requires int 'member_address'",
235                )
236            if not await self._consists.remove_member(addr, m_addr):
237                raise JsonServiceError(
238                    404, f"consist {addr} has no member at {m_addr}",
239                )
240        elif action == "set_speed":
241            speed = data.get("speed")
242            if not isinstance(speed, (int, float)):
243                raise JsonServiceError(400, "consist.set_speed requires 'speed'")
244            await self._consists.set_speed(addr, float(speed))
245        elif action == "set_direction":
246            direction = data.get("direction")
247            if direction not in ("forward", "reverse"):
248                raise JsonServiceError(
249                    400, "consist.set_direction requires 'forward'|'reverse'",
250                )
251            await self._consists.set_direction(
252                addr,
253                ThrottleDirection.FORWARD if direction == "forward"
254                else ThrottleDirection.REVERSE,
255            )
256        elif action == "set_function":
257            fn = data.get("function")
258            on = data.get("on")
259            if not isinstance(fn, int) or not isinstance(on, bool):
260                raise JsonServiceError(
261                    400, "consist.set_function requires int 'function' + bool 'on'",
262                )
263            await self._consists.set_function(addr, fn, on)
264        elif action == "emergency_stop":
265            await self._consists.emergency_stop(addr)
266        else:
267            raise JsonServiceError(400, f"unknown consist action: {action!r}")
268        return _project(self._require(addr), self._consists)
269
270    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
271        data = data or {}
272        addr = _address_from(data)
273        if not await self._consists.delete(addr):
274            raise JsonServiceError(404, f"no consist at address {addr}")
275        return {"address": addr, "deleted": True}
class ConsistService(jmri_server.service.JsonService):
 98class ConsistService(JsonService):
 99    topic = "consist"
100
101    @property
102    def _consists(self) -> ConsistManager:
103        return self.ctx.connection.consists
104
105    def _require(self, address: int) -> Consist:
106        c = self._consists.get(address)
107        if c is None:
108            raise JsonServiceError(404, f"no consist at address {address}")
109        return c
110
111    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
112        return [_project(c, self._consists) for c in self._consists]
113
114    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
115        addr = _address_from(data or {})
116        return _project(self._require(addr), self._consists)
117
118    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
119        """Create-if-absent. Idempotent: returns the existing consist if
120        one already lives at ``address`` without overwriting its
121        members. Change comment/kind on an existing consist via a
122        follow-up ``post`` (not currently wired — the two fields are
123        immutable after create; a delete+recreate is the escape hatch).
124
125        Enforces two rules that were left implicit before:
126
127        * Consist address must be 1..127 (short only). CV19 is 7-bit,
128          so anything else is either meaningless (advanced) or
129          gratuitously inconsistent across kinds.
130        * Address must not collide with any existing roster loco. If
131          it did, the roster loco would silently participate in every
132          fan-out addressed to that consist — a footgun.
133        """
134        data = data or {}
135        addr = _address_from(data)
136        if not 1 <= addr <= CONSIST_ADDRESS_MAX:
137            raise JsonServiceError(
138                400,
139                f"consist address must be 1..{CONSIST_ADDRESS_MAX} short, "
140                f"got {addr}",
141            )
142        kind = data.get("kind")
143        comment = str(data.get("comment", ""))
144        # Validate kind against the model's Literal — raise 400 on typo.
145        if kind is not None and kind not in ("software", "command_station", "advanced"):
146            raise JsonServiceError(400, f"invalid consist kind: {kind!r}")
147        # Roster-collision check. Skip if the consist already exists
148        # at this address (idempotent create still wins).
149        if self._consists.get(addr) is None:
150            conflict = self._roster_conflict(addr)
151            if conflict is not None:
152                raise JsonServiceError(
153                    409,
154                    f"consist address {addr} collides with roster loco "
155                    f"{conflict!r} (short {addr}); pick a different "
156                    f"consist address or remove/renumber the loco.",
157                )
158        consist = await self._consists.create(addr, kind=kind, comment=comment)
159        return _project(consist, self._consists)
160
161    def _roster_conflict(self, addr: int) -> str | None:
162        """Return the id of any roster entry using *addr* as a short
163        DCC address, or ``None`` if there's no collision.
164
165        Only short addresses count — a long-address loco at the same
166        numeric value shares no wire space with a 1..127 consist. The
167        roster store is lazy-loaded here on first use (same pattern
168        as :class:`RosterService`) so the check works even when the
169        user's first action is Create-consist and no roster/throttle
170        page has been hit yet — that was a real bug where the check
171        silently short-circuited when navigation started on
172        ``/consists``.
173        """
174        store = self._roster_store()
175        if store is None:
176            return None
177        target = str(addr)
178        for entry in store:
179            if entry.long_address:
180                continue
181            if entry.dcc_address == target:
182                return entry.id
183        return None
184
185    def _roster_store(self) -> Any:
186        """Return the shared :class:`RosterStore`, constructing it on
187        first access if nothing else has touched it yet.
188
189        Uses the same context attribute (``_roster_store``) as
190        :class:`RosterService` so both see the same instance — no
191        double-load, and edits made through /roster show up in this
192        check immediately.
193        """
194        store = getattr(self.ctx, "_roster_store", None)
195        if store is not None:
196            return store
197        try:
198            from jmri_core.persistence.profile import Profile  # noqa: PLC0415
199            from jmri_server.roster_store import RosterStore  # noqa: PLC0415
200        except ImportError:
201            return None
202        profile = getattr(self.ctx, "_roster_profile", None) or Profile.default()
203        try:
204            store = RosterStore(profile=profile)
205        except Exception:  # noqa: BLE001
206            # No profile on disk, permission error, corrupt index — the
207            # conflict check is a courtesy, not a hard invariant. Bail
208            # rather than fail create.
209            return None
210        self.ctx._roster_store = store  # type: ignore[attr-defined]
211        return store
212
213    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
214        data = data or {}
215        addr = _address_from(data)
216        action = data.get("action")
217        # Every action targets an existing consist.
218        self._require(addr)
219        if action == "add_member":
220            m = data.get("member") or {}
221            member_addr = m.get("address")
222            if not isinstance(member_addr, int):
223                raise JsonServiceError(
224                    400, "consist.add_member requires 'member.address'",
225                )
226            await self._consists.add_member(
227                addr, member_addr,
228                long_address=bool(m.get("long_address", False)),
229                direction_normal=bool(m.get("direction_normal", True)),
230            )
231        elif action == "remove_member":
232            m_addr = data.get("member_address")
233            if not isinstance(m_addr, int):
234                raise JsonServiceError(
235                    400, "consist.remove_member requires int 'member_address'",
236                )
237            if not await self._consists.remove_member(addr, m_addr):
238                raise JsonServiceError(
239                    404, f"consist {addr} has no member at {m_addr}",
240                )
241        elif action == "set_speed":
242            speed = data.get("speed")
243            if not isinstance(speed, (int, float)):
244                raise JsonServiceError(400, "consist.set_speed requires 'speed'")
245            await self._consists.set_speed(addr, float(speed))
246        elif action == "set_direction":
247            direction = data.get("direction")
248            if direction not in ("forward", "reverse"):
249                raise JsonServiceError(
250                    400, "consist.set_direction requires 'forward'|'reverse'",
251                )
252            await self._consists.set_direction(
253                addr,
254                ThrottleDirection.FORWARD if direction == "forward"
255                else ThrottleDirection.REVERSE,
256            )
257        elif action == "set_function":
258            fn = data.get("function")
259            on = data.get("on")
260            if not isinstance(fn, int) or not isinstance(on, bool):
261                raise JsonServiceError(
262                    400, "consist.set_function requires int 'function' + bool 'on'",
263                )
264            await self._consists.set_function(addr, fn, on)
265        elif action == "emergency_stop":
266            await self._consists.emergency_stop(addr)
267        else:
268            raise JsonServiceError(400, f"unknown consist action: {action!r}")
269        return _project(self._require(addr), self._consists)
270
271    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
272        data = data or {}
273        addr = _address_from(data)
274        if not await self._consists.delete(addr):
275            raise JsonServiceError(404, f"no consist at address {addr}")
276        return {"address": addr, "deleted": True}

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 = 'consist'
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
111    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
112        return [_project(c, self._consists) for c in self._consists]
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
114    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
115        addr = _address_from(data or {})
116        return _project(self._require(addr), self._consists)
async def on_put(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
118    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
119        """Create-if-absent. Idempotent: returns the existing consist if
120        one already lives at ``address`` without overwriting its
121        members. Change comment/kind on an existing consist via a
122        follow-up ``post`` (not currently wired — the two fields are
123        immutable after create; a delete+recreate is the escape hatch).
124
125        Enforces two rules that were left implicit before:
126
127        * Consist address must be 1..127 (short only). CV19 is 7-bit,
128          so anything else is either meaningless (advanced) or
129          gratuitously inconsistent across kinds.
130        * Address must not collide with any existing roster loco. If
131          it did, the roster loco would silently participate in every
132          fan-out addressed to that consist — a footgun.
133        """
134        data = data or {}
135        addr = _address_from(data)
136        if not 1 <= addr <= CONSIST_ADDRESS_MAX:
137            raise JsonServiceError(
138                400,
139                f"consist address must be 1..{CONSIST_ADDRESS_MAX} short, "
140                f"got {addr}",
141            )
142        kind = data.get("kind")
143        comment = str(data.get("comment", ""))
144        # Validate kind against the model's Literal — raise 400 on typo.
145        if kind is not None and kind not in ("software", "command_station", "advanced"):
146            raise JsonServiceError(400, f"invalid consist kind: {kind!r}")
147        # Roster-collision check. Skip if the consist already exists
148        # at this address (idempotent create still wins).
149        if self._consists.get(addr) is None:
150            conflict = self._roster_conflict(addr)
151            if conflict is not None:
152                raise JsonServiceError(
153                    409,
154                    f"consist address {addr} collides with roster loco "
155                    f"{conflict!r} (short {addr}); pick a different "
156                    f"consist address or remove/renumber the loco.",
157                )
158        consist = await self._consists.create(addr, kind=kind, comment=comment)
159        return _project(consist, self._consists)

Create-if-absent. Idempotent: returns the existing consist if one already lives at address without overwriting its members. Change comment/kind on an existing consist via a follow-up post (not currently wired — the two fields are immutable after create; a delete+recreate is the escape hatch).

Enforces two rules that were left implicit before:

  • Consist address must be 1..127 (short only). CV19 is 7-bit, so anything else is either meaningless (advanced) or gratuitously inconsistent across kinds.
  • Address must not collide with any existing roster loco. If it did, the roster loco would silently participate in every fan-out addressed to that consist — a footgun.
async def on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
213    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
214        data = data or {}
215        addr = _address_from(data)
216        action = data.get("action")
217        # Every action targets an existing consist.
218        self._require(addr)
219        if action == "add_member":
220            m = data.get("member") or {}
221            member_addr = m.get("address")
222            if not isinstance(member_addr, int):
223                raise JsonServiceError(
224                    400, "consist.add_member requires 'member.address'",
225                )
226            await self._consists.add_member(
227                addr, member_addr,
228                long_address=bool(m.get("long_address", False)),
229                direction_normal=bool(m.get("direction_normal", True)),
230            )
231        elif action == "remove_member":
232            m_addr = data.get("member_address")
233            if not isinstance(m_addr, int):
234                raise JsonServiceError(
235                    400, "consist.remove_member requires int 'member_address'",
236                )
237            if not await self._consists.remove_member(addr, m_addr):
238                raise JsonServiceError(
239                    404, f"consist {addr} has no member at {m_addr}",
240                )
241        elif action == "set_speed":
242            speed = data.get("speed")
243            if not isinstance(speed, (int, float)):
244                raise JsonServiceError(400, "consist.set_speed requires 'speed'")
245            await self._consists.set_speed(addr, float(speed))
246        elif action == "set_direction":
247            direction = data.get("direction")
248            if direction not in ("forward", "reverse"):
249                raise JsonServiceError(
250                    400, "consist.set_direction requires 'forward'|'reverse'",
251                )
252            await self._consists.set_direction(
253                addr,
254                ThrottleDirection.FORWARD if direction == "forward"
255                else ThrottleDirection.REVERSE,
256            )
257        elif action == "set_function":
258            fn = data.get("function")
259            on = data.get("on")
260            if not isinstance(fn, int) or not isinstance(on, bool):
261                raise JsonServiceError(
262                    400, "consist.set_function requires int 'function' + bool 'on'",
263                )
264            await self._consists.set_function(addr, fn, on)
265        elif action == "emergency_stop":
266            await self._consists.emergency_stop(addr)
267        else:
268            raise JsonServiceError(400, f"unknown consist action: {action!r}")
269        return _project(self._require(addr), self._consists)
async def on_delete(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
271    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
272        data = data or {}
273        addr = _address_from(data)
274        if not await self._consists.delete(addr):
275            raise JsonServiceError(404, f"no consist at address {addr}")
276        return {"address": addr, "deleted": True}