jmri_core.beans.consist_manager

ConsistManager — CRUD for consists + fan-out throttle control.

A consist is an ordered set of locomotives that operate as one — an MU (multi-unit) group. Three flavours exist in the DCC world:

  • Advanced (aka NMRA, aka CV19) — each loco's CV19 holds a shared short address; the decoder itself responds to commands on that address. Set-up is a decoder programming task; running the group is transparent afterwards.
  • Command station — the CS maintains the group in its own memory and forwards commands sent to the group's "handle" to every member. LocoNet's OPC_LINK_SLOTS, NCE's consist memory table, and DCC-EX's <C> command are all this shape.
  • Software — JMRI holds the group; every command sent to the group fans out into individual throttle commands, one per member. Works on any driver because it only uses the ThrottleManager API — the fallback for drivers that don't (or can't) do hardware consisting.

This module ships:

  • the base ConsistManager — CRUD + fan-out helpers that work for every driver via the connection's ThrottleManager;
  • subclass hooks so drivers can override the wire ops without changing the public surface.

Driver overrides live in each driver package (jmri_loconet.consist, jmri_nce.consist, etc.) and are wired via SystemConnection._make_consist_manager().

  1"""``ConsistManager`` — CRUD for consists + fan-out throttle control.
  2
  3A consist is an ordered set of locomotives that operate as one — an MU
  4(multi-unit) group. Three flavours exist in the DCC world:
  5
  6* **Advanced** (aka *NMRA*, aka *CV19*) — each loco's CV19 holds a
  7  shared short address; the decoder itself responds to commands on
  8  that address. Set-up is a decoder programming task; running the
  9  group is transparent afterwards.
 10* **Command station** — the CS maintains the group in its own memory
 11  and forwards commands sent to the group's "handle" to every
 12  member. LocoNet's ``OPC_LINK_SLOTS``, NCE's consist memory table,
 13  and DCC-EX's ``<C>`` command are all this shape.
 14* **Software** — JMRI holds the group; every command sent to the
 15  group fans out into individual throttle commands, one per member.
 16  Works on any driver because it only uses the ``ThrottleManager``
 17  API — the fallback for drivers that don't (or can't) do hardware
 18  consisting.
 19
 20This module ships:
 21
 22* the base :class:`ConsistManager` — CRUD + fan-out helpers that work
 23  for every driver via the connection's :class:`ThrottleManager`;
 24* subclass hooks so drivers can override the wire ops without
 25  changing the public surface.
 26
 27Driver overrides live in each driver package (``jmri_loconet.consist``,
 28``jmri_nce.consist``, etc.) and are wired via
 29:meth:`SystemConnection._make_consist_manager`.
 30"""
 31from __future__ import annotations
 32
 33from collections.abc import Iterator
 34from typing import TYPE_CHECKING
 35
 36from jmri_core.beans.consist import Consist, ConsistKind, ConsistLoco
 37from jmri_core.events import Signal
 38from jmri_core.state import ThrottleDirection
 39
 40if TYPE_CHECKING:
 41    from jmri_core.beans.programmer import ProgrammerManager
 42    from jmri_core.beans.throttle_manager import ThrottleManager
 43
 44_log = __import__("logging").getLogger(__name__)
 45
 46
 47class ConsistManager:
 48    """Track a set of consists and fan throttle commands to their members.
 49
 50    The base implementation is the **software consist** manager: it
 51    stores consists in a dict keyed by the group's DCC address and
 52    executes group speed/direction/function commands by looping over
 53    every member's :class:`Throttle`. Drivers that support hardware
 54    consisting subclass this and override :meth:`_after_create` and
 55    :meth:`_before_delete` to install/remove the wire-level group;
 56    they typically leave the fan-out helpers alone since the base's
 57    per-loco commands are still valid even when the CS is grouping.
 58
 59    ``created`` and ``deleted`` are :class:`Signal` fan-outs so the
 60    push side of the JSON server, the WiThrottle broadcast, and the
 61    web UI's live table can react without polling.
 62    """
 63
 64    default_kind: ConsistKind = "software"
 65
 66    def __init__(
 67        self, throttles: ThrottleManager, system_prefix: str,
 68        programmers: ProgrammerManager | None = None,
 69    ) -> None:
 70        if not system_prefix:
 71            raise ValueError("system_prefix must be a non-empty string")
 72        self._throttles = throttles
 73        # Ops-mode programmer manager, used by the ``advanced`` kind to
 74        # write CV19 on each member decoder as they join or leave. May
 75        # be ``None`` on non-hardware setups (tests, InternalConnection);
 76        # advanced consists degrade silently to a no-op in that case
 77        # rather than raising.
 78        self._programmers: ProgrammerManager | None = programmers
 79        self.system_prefix = system_prefix
 80        self._consists: dict[int, Consist] = {}
 81        # Runtime drive state per consist address. Not persisted — the
 82        # consist itself is pure config (membership + wire kind), and
 83        # loco throttles carry the authoritative state. But the UI
 84        # needs a per-consist "last commanded" value to render the
 85        # slider position and highlight the active direction button;
 86        # without it every drive command re-renders the card at the
 87        # hardcoded defaults and the user sees the slider snap back to
 88        # 0 on every drag-release. Reset on delete.
 89        self._speeds: dict[int, float] = {}
 90        self._directions: dict[int, ThrottleDirection] = {}
 91        self.created: Signal[Consist] = Signal()
 92        self.deleted: Signal[Consist] = Signal()
 93        self.updated: Signal[Consist] = Signal()
 94
 95    def rebind_throttles(self, throttles: ThrottleManager) -> None:
 96        """Point the fan-out at a new :class:`ThrottleManager`.
 97
 98        Base :meth:`SystemConnection.__init__` builds the consist manager
 99        before drivers get a chance to install their hardware-aware
100        throttle manager in :meth:`open`. Without this hook the consist
101        manager keeps a stale reference to the pre-open placeholder and
102        every fan-out silently drives plain-Python :class:`Throttle`
103        beans that never touch the wire — the consist would look like
104        it's working (bean state advances, signals fire) while the
105        actual locomotive sits still. Drivers call this from ``open()``
106        immediately after replacing ``self.throttles``.
107        """
108        self._throttles = throttles
109
110    def rebind_programmers(
111        self, programmers: ProgrammerManager | None
112    ) -> None:
113        """Point the CV19 write path at a new :class:`ProgrammerManager`.
114
115        Same rationale as :meth:`rebind_throttles` — the driver's
116        hardware-aware programmer manager isn't in place until
117        :meth:`open` runs. Advanced consists need ops-mode access to
118        program CV19 on each member; without a real programmer the
119        writes silently become no-ops.
120        """
121        self._programmers = programmers
122
123    def current_speed(self, address: int) -> float:
124        """Last commanded fan-out speed for this consist, or 0.0."""
125        return self._speeds.get(address, 0.0)
126
127    def current_direction(self, address: int) -> ThrottleDirection:
128        """Last commanded direction for this consist, or FORWARD."""
129        return self._directions.get(address, ThrottleDirection.FORWARD)
130
131    # ---- CRUD ------------------------------------------------------------
132
133    async def create(
134        self,
135        address: int,
136        *,
137        kind: ConsistKind | None = None,
138        comment: str = "",
139    ) -> Consist:
140        """Create a new consist and register it. Idempotent by address.
141
142        Returns the existing consist if one already lives at *address*
143        rather than raising — matches JMRI's tolerance for repeated
144        create calls from restore paths.
145
146        Consist addresses are constrained to 1..127 short by the
147        ``Consist`` model; the caller doesn't need a ``long_address``
148        flag because there is no long-address form of a consist. See
149        :data:`jmri_core.beans.consist.CONSIST_ADDRESS_MAX`.
150        """
151        existing = self._consists.get(address)
152        if existing is not None:
153            return existing
154        consist = Consist(
155            address=address,
156            kind=kind or self.default_kind, comment=comment,
157        )
158        self._consists[address] = consist
159        await self._after_create(consist)
160        await self.created.emit(consist)
161        return consist
162
163    async def delete(self, address: int) -> bool:
164        """Remove the consist. Returns ``True`` if one was found."""
165        consist = self._consists.pop(address, None)
166        if consist is None:
167            return False
168        self._speeds.pop(address, None)
169        self._directions.pop(address, None)
170        await self._before_delete(consist)
171        await self.deleted.emit(consist)
172        return True
173
174    def get(self, address: int) -> Consist | None:
175        return self._consists.get(address)
176
177    def __iter__(self) -> Iterator[Consist]:
178        return iter(self._consists.values())
179
180    def __len__(self) -> int:
181        return len(self._consists)
182
183    def __contains__(self, address: int) -> bool:
184        return address in self._consists
185
186    # ---- membership -----------------------------------------------------
187
188    async def add_member(
189        self,
190        consist_address: int,
191        member_address: int,
192        *,
193        long_address: bool = False,
194        direction_normal: bool = True,
195    ) -> ConsistLoco:
196        """Add *member_address* to the consist at *consist_address*.
197
198        The member is appended at the end. If a loco with the same
199        address already exists in the consist, its direction flag is
200        updated in place and the existing :class:`ConsistLoco` is
201        returned. Fires :attr:`updated` on the consist so subscribers
202        can react to membership changes.
203        """
204        consist = self._consists.get(consist_address)
205        if consist is None:
206            raise KeyError(f"no consist at address {consist_address}")
207        for loco in consist.locos:
208            if loco.address == member_address:
209                loco.direction_normal = direction_normal
210                await self._after_membership_change(consist)
211                if consist.kind == "advanced":
212                    await self._program_cv19(
213                        member_address, long_address=loco.long_address,
214                        consist_address=consist_address,
215                        direction_normal=direction_normal,
216                    )
217                await self.updated.emit(consist)
218                return loco
219        loco = ConsistLoco(
220            address=member_address,
221            long_address=long_address,
222            direction_normal=direction_normal,
223            position=len(consist.locos),
224        )
225        consist.locos.append(loco)
226        await self._after_membership_change(consist)
227        if consist.kind == "advanced":
228            await self._program_cv19(
229                member_address, long_address=long_address,
230                consist_address=consist_address,
231                direction_normal=direction_normal,
232            )
233        await self.updated.emit(consist)
234        return loco
235
236    async def _program_cv19(
237        self,
238        member_address: int,
239        *,
240        long_address: bool,
241        consist_address: int,
242        direction_normal: bool,
243    ) -> None:
244        """Write CV19 on *member_address*'s decoder for an advanced
245        consist.
246
247        Per NMRA S-9.2.2, CV19 holds a 7-bit consist address in bits
248        0..6 and the direction flag in bit 7. Bit 7 set means the loco
249        runs in the reverse direction relative to the consist — i.e.
250        ``direction_normal=False``. A value of 0 in the low 7 bits
251        disables the consist for that decoder.
252        """
253        prog_mgr = self._programmers
254        if prog_mgr is None:
255            _log.debug(
256                "advanced consist %d: no programmer manager, skipping "
257                "CV19 write for member %d",
258                consist_address, member_address,
259            )
260            return
261        prog = prog_mgr.ops_programmer(
262            member_address, long_address=long_address,
263        )
264        if prog is None:
265            _log.debug(
266                "advanced consist %d: driver returned no ops "
267                "programmer for member %d; CV19 not written",
268                consist_address, member_address,
269            )
270            return
271        value = consist_address & 0x7F
272        if not direction_normal:
273            value |= 0x80
274        try:
275            await prog.write_cv(19, value)
276        except Exception as exc:  # noqa: BLE001
277            # POM writes can fail (no ack, decoder not on the track,
278            # RailCom garble). Log and keep going — the consist itself
279            # remains configured; the user can re-add the member after
280            # putting the loco on the rails.
281            _log.warning(
282                "CV19 write failed for consist %d, member %d "
283                "(value=%d): %s",
284                consist_address, member_address, value, exc,
285            )
286
287    async def _clear_cv19(
288        self, member_address: int, *, long_address: bool,
289        consist_address: int,
290    ) -> None:
291        """Reset CV19 to 0 on the ex-member's decoder so it stops
292        answering to the consist address."""
293        prog_mgr = self._programmers
294        if prog_mgr is None:
295            return
296        prog = prog_mgr.ops_programmer(
297            member_address, long_address=long_address,
298        )
299        if prog is None:
300            return
301        try:
302            await prog.write_cv(19, 0)
303        except Exception as exc:  # noqa: BLE001
304            _log.warning(
305                "CV19 clear failed for consist %d, ex-member %d: %s",
306                consist_address, member_address, exc,
307            )
308
309    async def remove_member(
310        self, consist_address: int, member_address: int,
311    ) -> bool:
312        """Drop *member_address* from the consist. Returns True if found.
313
314        Re-numbers surviving members' ``position`` fields so they stay
315        contiguous — the ordering is used for lead-loco / rear-loco
316        semantics in DCC-EX and NCE displays.
317        """
318        consist = self._consists.get(consist_address)
319        if consist is None:
320            return False
321        removed = False
322        removed_long_address = False
323        surviving: list[ConsistLoco] = []
324        for loco in consist.locos:
325            if loco.address == member_address and not removed:
326                removed = True
327                # Capture before the loco leaves — the ops programmer
328                # for the CV19 clear is keyed by the decoder's real
329                # address form (short vs long).
330                removed_long_address = loco.long_address
331                continue
332            surviving.append(loco)
333        if not removed:
334            return False
335        # Renumber positions.
336        for i, loco in enumerate(surviving):
337            loco.position = i
338        consist.locos = surviving
339        await self._after_membership_change(consist)
340        if consist.kind == "advanced":
341            await self._clear_cv19(
342                member_address, long_address=removed_long_address,
343                consist_address=consist_address,
344            )
345        await self.updated.emit(consist)
346        return True
347
348    # ---- fan-out throttle control ---------------------------------------
349
350    async def set_speed(self, consist_address: int, speed: float) -> None:
351        """Fan a speed command to every member throttle.
352
353        Members with ``direction_normal=False`` are treated identically
354        for speed (only direction is inverted). Missing throttles
355        (member never acquired) are silently acquired first so the
356        first speed command on a fresh consist works.
357
358        Records the commanded speed on the manager and emits ``updated``
359        so any subscriber (the /consists page's WebSocket bus in
360        particular) re-renders the card with the slider at the new
361        position instead of snapping back to zero.
362        """
363        consist = self._require(consist_address)
364        for member in consist.locos:
365            throttle = await self._throttles.acquire(
366                member.address, long_address=member.long_address,
367            )
368            await throttle.set_speed(speed)
369        self._speeds[consist_address] = speed
370        await self.updated.emit(consist)
371
372    async def set_direction(
373        self, consist_address: int, direction: ThrottleDirection,
374    ) -> None:
375        """Fan a direction command to every member, honouring per-loco
376        orientation.
377
378        A member with ``direction_normal=False`` is oriented backwards
379        in the consist, so it receives the *opposite* direction to keep
380        the whole train moving the same way — this is the core reason
381        consists exist at the software level.
382        """
383        consist = self._require(consist_address)
384        opposite = (
385            ThrottleDirection.REVERSE
386            if direction is ThrottleDirection.FORWARD
387            else ThrottleDirection.FORWARD
388        )
389        for member in consist.locos:
390            throttle = await self._throttles.acquire(
391                member.address, long_address=member.long_address,
392            )
393            wire = direction if member.direction_normal else opposite
394            await throttle.set_direction(wire)
395        self._directions[consist_address] = direction
396        await self.updated.emit(consist)
397
398    async def set_function(
399        self, consist_address: int, fn: int, on: bool,
400    ) -> None:
401        """Fan a function command to every member.
402
403        JMRI's convention is that all lit functions (F0/headlight, F1/
404        bell, F2/horn) fire on every loco; layout-specific automation
405        that wants function isolation should drive individual throttles
406        directly.
407        """
408        consist = self._require(consist_address)
409        for member in consist.locos:
410            throttle = await self._throttles.acquire(
411                member.address, long_address=member.long_address,
412            )
413            await throttle.set_function(fn, on)
414
415    async def emergency_stop(self, consist_address: int) -> None:
416        """Fan an emergency-stop to every member throttle."""
417        consist = self._require(consist_address)
418        for member in consist.locos:
419            throttle = await self._throttles.acquire(
420                member.address, long_address=member.long_address,
421            )
422            await throttle.emergency_stop()
423        # Reset UI speed so the slider snaps back to 0 after an e-stop
424        # rather than staying at whatever the user last commanded.
425        self._speeds[consist_address] = 0.0
426        await self.updated.emit(consist)
427
428    # ---- driver hooks ---------------------------------------------------
429
430    async def _after_create(self, consist: Consist) -> None:
431        """Called after a new consist is registered. Driver hook.
432
433        Subclasses install the wire-level group here — LocoNet's
434        ``LINK_SLOTS``, NCE's consist-memory write, DCC-EX's ``<C>``.
435        The base impl is a no-op (software consist).
436        """
437        return
438
439    async def _before_delete(self, consist: Consist) -> None:
440        """Called before a consist is dropped from the registry.
441
442        Subclasses tear the wire-level group down here.
443        """
444        return
445
446    async def _after_membership_change(self, consist: Consist) -> None:
447        """Called after ``add_member`` / ``remove_member`` mutates the
448        consist's locos list. Subclasses re-sync the wire-level group.
449        """
450        return
451
452    # ---- internals ------------------------------------------------------
453
454    def _require(self, address: int) -> Consist:
455        consist = self._consists.get(address)
456        if consist is None:
457            raise KeyError(f"no consist at address {address}")
458        return consist
class ConsistManager:
 48class ConsistManager:
 49    """Track a set of consists and fan throttle commands to their members.
 50
 51    The base implementation is the **software consist** manager: it
 52    stores consists in a dict keyed by the group's DCC address and
 53    executes group speed/direction/function commands by looping over
 54    every member's :class:`Throttle`. Drivers that support hardware
 55    consisting subclass this and override :meth:`_after_create` and
 56    :meth:`_before_delete` to install/remove the wire-level group;
 57    they typically leave the fan-out helpers alone since the base's
 58    per-loco commands are still valid even when the CS is grouping.
 59
 60    ``created`` and ``deleted`` are :class:`Signal` fan-outs so the
 61    push side of the JSON server, the WiThrottle broadcast, and the
 62    web UI's live table can react without polling.
 63    """
 64
 65    default_kind: ConsistKind = "software"
 66
 67    def __init__(
 68        self, throttles: ThrottleManager, system_prefix: str,
 69        programmers: ProgrammerManager | None = None,
 70    ) -> None:
 71        if not system_prefix:
 72            raise ValueError("system_prefix must be a non-empty string")
 73        self._throttles = throttles
 74        # Ops-mode programmer manager, used by the ``advanced`` kind to
 75        # write CV19 on each member decoder as they join or leave. May
 76        # be ``None`` on non-hardware setups (tests, InternalConnection);
 77        # advanced consists degrade silently to a no-op in that case
 78        # rather than raising.
 79        self._programmers: ProgrammerManager | None = programmers
 80        self.system_prefix = system_prefix
 81        self._consists: dict[int, Consist] = {}
 82        # Runtime drive state per consist address. Not persisted — the
 83        # consist itself is pure config (membership + wire kind), and
 84        # loco throttles carry the authoritative state. But the UI
 85        # needs a per-consist "last commanded" value to render the
 86        # slider position and highlight the active direction button;
 87        # without it every drive command re-renders the card at the
 88        # hardcoded defaults and the user sees the slider snap back to
 89        # 0 on every drag-release. Reset on delete.
 90        self._speeds: dict[int, float] = {}
 91        self._directions: dict[int, ThrottleDirection] = {}
 92        self.created: Signal[Consist] = Signal()
 93        self.deleted: Signal[Consist] = Signal()
 94        self.updated: Signal[Consist] = Signal()
 95
 96    def rebind_throttles(self, throttles: ThrottleManager) -> None:
 97        """Point the fan-out at a new :class:`ThrottleManager`.
 98
 99        Base :meth:`SystemConnection.__init__` builds the consist manager
100        before drivers get a chance to install their hardware-aware
101        throttle manager in :meth:`open`. Without this hook the consist
102        manager keeps a stale reference to the pre-open placeholder and
103        every fan-out silently drives plain-Python :class:`Throttle`
104        beans that never touch the wire — the consist would look like
105        it's working (bean state advances, signals fire) while the
106        actual locomotive sits still. Drivers call this from ``open()``
107        immediately after replacing ``self.throttles``.
108        """
109        self._throttles = throttles
110
111    def rebind_programmers(
112        self, programmers: ProgrammerManager | None
113    ) -> None:
114        """Point the CV19 write path at a new :class:`ProgrammerManager`.
115
116        Same rationale as :meth:`rebind_throttles` — the driver's
117        hardware-aware programmer manager isn't in place until
118        :meth:`open` runs. Advanced consists need ops-mode access to
119        program CV19 on each member; without a real programmer the
120        writes silently become no-ops.
121        """
122        self._programmers = programmers
123
124    def current_speed(self, address: int) -> float:
125        """Last commanded fan-out speed for this consist, or 0.0."""
126        return self._speeds.get(address, 0.0)
127
128    def current_direction(self, address: int) -> ThrottleDirection:
129        """Last commanded direction for this consist, or FORWARD."""
130        return self._directions.get(address, ThrottleDirection.FORWARD)
131
132    # ---- CRUD ------------------------------------------------------------
133
134    async def create(
135        self,
136        address: int,
137        *,
138        kind: ConsistKind | None = None,
139        comment: str = "",
140    ) -> Consist:
141        """Create a new consist and register it. Idempotent by address.
142
143        Returns the existing consist if one already lives at *address*
144        rather than raising — matches JMRI's tolerance for repeated
145        create calls from restore paths.
146
147        Consist addresses are constrained to 1..127 short by the
148        ``Consist`` model; the caller doesn't need a ``long_address``
149        flag because there is no long-address form of a consist. See
150        :data:`jmri_core.beans.consist.CONSIST_ADDRESS_MAX`.
151        """
152        existing = self._consists.get(address)
153        if existing is not None:
154            return existing
155        consist = Consist(
156            address=address,
157            kind=kind or self.default_kind, comment=comment,
158        )
159        self._consists[address] = consist
160        await self._after_create(consist)
161        await self.created.emit(consist)
162        return consist
163
164    async def delete(self, address: int) -> bool:
165        """Remove the consist. Returns ``True`` if one was found."""
166        consist = self._consists.pop(address, None)
167        if consist is None:
168            return False
169        self._speeds.pop(address, None)
170        self._directions.pop(address, None)
171        await self._before_delete(consist)
172        await self.deleted.emit(consist)
173        return True
174
175    def get(self, address: int) -> Consist | None:
176        return self._consists.get(address)
177
178    def __iter__(self) -> Iterator[Consist]:
179        return iter(self._consists.values())
180
181    def __len__(self) -> int:
182        return len(self._consists)
183
184    def __contains__(self, address: int) -> bool:
185        return address in self._consists
186
187    # ---- membership -----------------------------------------------------
188
189    async def add_member(
190        self,
191        consist_address: int,
192        member_address: int,
193        *,
194        long_address: bool = False,
195        direction_normal: bool = True,
196    ) -> ConsistLoco:
197        """Add *member_address* to the consist at *consist_address*.
198
199        The member is appended at the end. If a loco with the same
200        address already exists in the consist, its direction flag is
201        updated in place and the existing :class:`ConsistLoco` is
202        returned. Fires :attr:`updated` on the consist so subscribers
203        can react to membership changes.
204        """
205        consist = self._consists.get(consist_address)
206        if consist is None:
207            raise KeyError(f"no consist at address {consist_address}")
208        for loco in consist.locos:
209            if loco.address == member_address:
210                loco.direction_normal = direction_normal
211                await self._after_membership_change(consist)
212                if consist.kind == "advanced":
213                    await self._program_cv19(
214                        member_address, long_address=loco.long_address,
215                        consist_address=consist_address,
216                        direction_normal=direction_normal,
217                    )
218                await self.updated.emit(consist)
219                return loco
220        loco = ConsistLoco(
221            address=member_address,
222            long_address=long_address,
223            direction_normal=direction_normal,
224            position=len(consist.locos),
225        )
226        consist.locos.append(loco)
227        await self._after_membership_change(consist)
228        if consist.kind == "advanced":
229            await self._program_cv19(
230                member_address, long_address=long_address,
231                consist_address=consist_address,
232                direction_normal=direction_normal,
233            )
234        await self.updated.emit(consist)
235        return loco
236
237    async def _program_cv19(
238        self,
239        member_address: int,
240        *,
241        long_address: bool,
242        consist_address: int,
243        direction_normal: bool,
244    ) -> None:
245        """Write CV19 on *member_address*'s decoder for an advanced
246        consist.
247
248        Per NMRA S-9.2.2, CV19 holds a 7-bit consist address in bits
249        0..6 and the direction flag in bit 7. Bit 7 set means the loco
250        runs in the reverse direction relative to the consist — i.e.
251        ``direction_normal=False``. A value of 0 in the low 7 bits
252        disables the consist for that decoder.
253        """
254        prog_mgr = self._programmers
255        if prog_mgr is None:
256            _log.debug(
257                "advanced consist %d: no programmer manager, skipping "
258                "CV19 write for member %d",
259                consist_address, member_address,
260            )
261            return
262        prog = prog_mgr.ops_programmer(
263            member_address, long_address=long_address,
264        )
265        if prog is None:
266            _log.debug(
267                "advanced consist %d: driver returned no ops "
268                "programmer for member %d; CV19 not written",
269                consist_address, member_address,
270            )
271            return
272        value = consist_address & 0x7F
273        if not direction_normal:
274            value |= 0x80
275        try:
276            await prog.write_cv(19, value)
277        except Exception as exc:  # noqa: BLE001
278            # POM writes can fail (no ack, decoder not on the track,
279            # RailCom garble). Log and keep going — the consist itself
280            # remains configured; the user can re-add the member after
281            # putting the loco on the rails.
282            _log.warning(
283                "CV19 write failed for consist %d, member %d "
284                "(value=%d): %s",
285                consist_address, member_address, value, exc,
286            )
287
288    async def _clear_cv19(
289        self, member_address: int, *, long_address: bool,
290        consist_address: int,
291    ) -> None:
292        """Reset CV19 to 0 on the ex-member's decoder so it stops
293        answering to the consist address."""
294        prog_mgr = self._programmers
295        if prog_mgr is None:
296            return
297        prog = prog_mgr.ops_programmer(
298            member_address, long_address=long_address,
299        )
300        if prog is None:
301            return
302        try:
303            await prog.write_cv(19, 0)
304        except Exception as exc:  # noqa: BLE001
305            _log.warning(
306                "CV19 clear failed for consist %d, ex-member %d: %s",
307                consist_address, member_address, exc,
308            )
309
310    async def remove_member(
311        self, consist_address: int, member_address: int,
312    ) -> bool:
313        """Drop *member_address* from the consist. Returns True if found.
314
315        Re-numbers surviving members' ``position`` fields so they stay
316        contiguous — the ordering is used for lead-loco / rear-loco
317        semantics in DCC-EX and NCE displays.
318        """
319        consist = self._consists.get(consist_address)
320        if consist is None:
321            return False
322        removed = False
323        removed_long_address = False
324        surviving: list[ConsistLoco] = []
325        for loco in consist.locos:
326            if loco.address == member_address and not removed:
327                removed = True
328                # Capture before the loco leaves — the ops programmer
329                # for the CV19 clear is keyed by the decoder's real
330                # address form (short vs long).
331                removed_long_address = loco.long_address
332                continue
333            surviving.append(loco)
334        if not removed:
335            return False
336        # Renumber positions.
337        for i, loco in enumerate(surviving):
338            loco.position = i
339        consist.locos = surviving
340        await self._after_membership_change(consist)
341        if consist.kind == "advanced":
342            await self._clear_cv19(
343                member_address, long_address=removed_long_address,
344                consist_address=consist_address,
345            )
346        await self.updated.emit(consist)
347        return True
348
349    # ---- fan-out throttle control ---------------------------------------
350
351    async def set_speed(self, consist_address: int, speed: float) -> None:
352        """Fan a speed command to every member throttle.
353
354        Members with ``direction_normal=False`` are treated identically
355        for speed (only direction is inverted). Missing throttles
356        (member never acquired) are silently acquired first so the
357        first speed command on a fresh consist works.
358
359        Records the commanded speed on the manager and emits ``updated``
360        so any subscriber (the /consists page's WebSocket bus in
361        particular) re-renders the card with the slider at the new
362        position instead of snapping back to zero.
363        """
364        consist = self._require(consist_address)
365        for member in consist.locos:
366            throttle = await self._throttles.acquire(
367                member.address, long_address=member.long_address,
368            )
369            await throttle.set_speed(speed)
370        self._speeds[consist_address] = speed
371        await self.updated.emit(consist)
372
373    async def set_direction(
374        self, consist_address: int, direction: ThrottleDirection,
375    ) -> None:
376        """Fan a direction command to every member, honouring per-loco
377        orientation.
378
379        A member with ``direction_normal=False`` is oriented backwards
380        in the consist, so it receives the *opposite* direction to keep
381        the whole train moving the same way — this is the core reason
382        consists exist at the software level.
383        """
384        consist = self._require(consist_address)
385        opposite = (
386            ThrottleDirection.REVERSE
387            if direction is ThrottleDirection.FORWARD
388            else ThrottleDirection.FORWARD
389        )
390        for member in consist.locos:
391            throttle = await self._throttles.acquire(
392                member.address, long_address=member.long_address,
393            )
394            wire = direction if member.direction_normal else opposite
395            await throttle.set_direction(wire)
396        self._directions[consist_address] = direction
397        await self.updated.emit(consist)
398
399    async def set_function(
400        self, consist_address: int, fn: int, on: bool,
401    ) -> None:
402        """Fan a function command to every member.
403
404        JMRI's convention is that all lit functions (F0/headlight, F1/
405        bell, F2/horn) fire on every loco; layout-specific automation
406        that wants function isolation should drive individual throttles
407        directly.
408        """
409        consist = self._require(consist_address)
410        for member in consist.locos:
411            throttle = await self._throttles.acquire(
412                member.address, long_address=member.long_address,
413            )
414            await throttle.set_function(fn, on)
415
416    async def emergency_stop(self, consist_address: int) -> None:
417        """Fan an emergency-stop to every member throttle."""
418        consist = self._require(consist_address)
419        for member in consist.locos:
420            throttle = await self._throttles.acquire(
421                member.address, long_address=member.long_address,
422            )
423            await throttle.emergency_stop()
424        # Reset UI speed so the slider snaps back to 0 after an e-stop
425        # rather than staying at whatever the user last commanded.
426        self._speeds[consist_address] = 0.0
427        await self.updated.emit(consist)
428
429    # ---- driver hooks ---------------------------------------------------
430
431    async def _after_create(self, consist: Consist) -> None:
432        """Called after a new consist is registered. Driver hook.
433
434        Subclasses install the wire-level group here — LocoNet's
435        ``LINK_SLOTS``, NCE's consist-memory write, DCC-EX's ``<C>``.
436        The base impl is a no-op (software consist).
437        """
438        return
439
440    async def _before_delete(self, consist: Consist) -> None:
441        """Called before a consist is dropped from the registry.
442
443        Subclasses tear the wire-level group down here.
444        """
445        return
446
447    async def _after_membership_change(self, consist: Consist) -> None:
448        """Called after ``add_member`` / ``remove_member`` mutates the
449        consist's locos list. Subclasses re-sync the wire-level group.
450        """
451        return
452
453    # ---- internals ------------------------------------------------------
454
455    def _require(self, address: int) -> Consist:
456        consist = self._consists.get(address)
457        if consist is None:
458            raise KeyError(f"no consist at address {address}")
459        return consist

Track a set of consists and fan throttle commands to their members.

The base implementation is the software consist manager: it stores consists in a dict keyed by the group's DCC address and executes group speed/direction/function commands by looping over every member's Throttle. Drivers that support hardware consisting subclass this and override _after_create() and _before_delete() to install/remove the wire-level group; they typically leave the fan-out helpers alone since the base's per-loco commands are still valid even when the CS is grouping.

created and deleted are Signal fan-outs so the push side of the JSON server, the WiThrottle broadcast, and the web UI's live table can react without polling.

ConsistManager( throttles: jmri_core.beans.ThrottleManager, system_prefix: str, programmers: jmri_core.beans.ProgrammerManager | None = None)
67    def __init__(
68        self, throttles: ThrottleManager, system_prefix: str,
69        programmers: ProgrammerManager | None = None,
70    ) -> None:
71        if not system_prefix:
72            raise ValueError("system_prefix must be a non-empty string")
73        self._throttles = throttles
74        # Ops-mode programmer manager, used by the ``advanced`` kind to
75        # write CV19 on each member decoder as they join or leave. May
76        # be ``None`` on non-hardware setups (tests, InternalConnection);
77        # advanced consists degrade silently to a no-op in that case
78        # rather than raising.
79        self._programmers: ProgrammerManager | None = programmers
80        self.system_prefix = system_prefix
81        self._consists: dict[int, Consist] = {}
82        # Runtime drive state per consist address. Not persisted — the
83        # consist itself is pure config (membership + wire kind), and
84        # loco throttles carry the authoritative state. But the UI
85        # needs a per-consist "last commanded" value to render the
86        # slider position and highlight the active direction button;
87        # without it every drive command re-renders the card at the
88        # hardcoded defaults and the user sees the slider snap back to
89        # 0 on every drag-release. Reset on delete.
90        self._speeds: dict[int, float] = {}
91        self._directions: dict[int, ThrottleDirection] = {}
92        self.created: Signal[Consist] = Signal()
93        self.deleted: Signal[Consist] = Signal()
94        self.updated: Signal[Consist] = Signal()
default_kind: Literal['advanced', 'command_station', 'software'] = 'software'
system_prefix
def rebind_throttles( self, throttles: jmri_core.beans.ThrottleManager) -> None:
 96    def rebind_throttles(self, throttles: ThrottleManager) -> None:
 97        """Point the fan-out at a new :class:`ThrottleManager`.
 98
 99        Base :meth:`SystemConnection.__init__` builds the consist manager
100        before drivers get a chance to install their hardware-aware
101        throttle manager in :meth:`open`. Without this hook the consist
102        manager keeps a stale reference to the pre-open placeholder and
103        every fan-out silently drives plain-Python :class:`Throttle`
104        beans that never touch the wire — the consist would look like
105        it's working (bean state advances, signals fire) while the
106        actual locomotive sits still. Drivers call this from ``open()``
107        immediately after replacing ``self.throttles``.
108        """
109        self._throttles = throttles

Point the fan-out at a new ThrottleManager.

Base SystemConnection.__init__() builds the consist manager before drivers get a chance to install their hardware-aware throttle manager in open(). Without this hook the consist manager keeps a stale reference to the pre-open placeholder and every fan-out silently drives plain-Python Throttle beans that never touch the wire — the consist would look like it's working (bean state advances, signals fire) while the actual locomotive sits still. Drivers call this from open() immediately after replacing self.throttles.

def rebind_programmers( self, programmers: jmri_core.beans.ProgrammerManager | None) -> None:
111    def rebind_programmers(
112        self, programmers: ProgrammerManager | None
113    ) -> None:
114        """Point the CV19 write path at a new :class:`ProgrammerManager`.
115
116        Same rationale as :meth:`rebind_throttles` — the driver's
117        hardware-aware programmer manager isn't in place until
118        :meth:`open` runs. Advanced consists need ops-mode access to
119        program CV19 on each member; without a real programmer the
120        writes silently become no-ops.
121        """
122        self._programmers = programmers

Point the CV19 write path at a new ProgrammerManager.

Same rationale as rebind_throttles() — the driver's hardware-aware programmer manager isn't in place until open() runs. Advanced consists need ops-mode access to program CV19 on each member; without a real programmer the writes silently become no-ops.

def current_speed(self, address: int) -> float:
124    def current_speed(self, address: int) -> float:
125        """Last commanded fan-out speed for this consist, or 0.0."""
126        return self._speeds.get(address, 0.0)

Last commanded fan-out speed for this consist, or 0.0.

def current_direction(self, address: int) -> jmri_core.state.ThrottleDirection:
128    def current_direction(self, address: int) -> ThrottleDirection:
129        """Last commanded direction for this consist, or FORWARD."""
130        return self._directions.get(address, ThrottleDirection.FORWARD)

Last commanded direction for this consist, or FORWARD.

async def create( self, address: int, *, kind: Optional[Literal['advanced', 'command_station', 'software']] = None, comment: str = '') -> jmri_core.beans.Consist:
134    async def create(
135        self,
136        address: int,
137        *,
138        kind: ConsistKind | None = None,
139        comment: str = "",
140    ) -> Consist:
141        """Create a new consist and register it. Idempotent by address.
142
143        Returns the existing consist if one already lives at *address*
144        rather than raising — matches JMRI's tolerance for repeated
145        create calls from restore paths.
146
147        Consist addresses are constrained to 1..127 short by the
148        ``Consist`` model; the caller doesn't need a ``long_address``
149        flag because there is no long-address form of a consist. See
150        :data:`jmri_core.beans.consist.CONSIST_ADDRESS_MAX`.
151        """
152        existing = self._consists.get(address)
153        if existing is not None:
154            return existing
155        consist = Consist(
156            address=address,
157            kind=kind or self.default_kind, comment=comment,
158        )
159        self._consists[address] = consist
160        await self._after_create(consist)
161        await self.created.emit(consist)
162        return consist

Create a new consist and register it. Idempotent by address.

Returns the existing consist if one already lives at address rather than raising — matches JMRI's tolerance for repeated create calls from restore paths.

Consist addresses are constrained to 1..127 short by the Consist model; the caller doesn't need a long_address flag because there is no long-address form of a consist. See jmri_core.beans.consist.CONSIST_ADDRESS_MAX.

async def delete(self, address: int) -> bool:
164    async def delete(self, address: int) -> bool:
165        """Remove the consist. Returns ``True`` if one was found."""
166        consist = self._consists.pop(address, None)
167        if consist is None:
168            return False
169        self._speeds.pop(address, None)
170        self._directions.pop(address, None)
171        await self._before_delete(consist)
172        await self.deleted.emit(consist)
173        return True

Remove the consist. Returns True if one was found.

def get(self, address: int) -> jmri_core.beans.Consist | None:
175    def get(self, address: int) -> Consist | None:
176        return self._consists.get(address)
async def add_member( self, consist_address: int, member_address: int, *, long_address: bool = False, direction_normal: bool = True) -> jmri_core.beans.ConsistLoco:
189    async def add_member(
190        self,
191        consist_address: int,
192        member_address: int,
193        *,
194        long_address: bool = False,
195        direction_normal: bool = True,
196    ) -> ConsistLoco:
197        """Add *member_address* to the consist at *consist_address*.
198
199        The member is appended at the end. If a loco with the same
200        address already exists in the consist, its direction flag is
201        updated in place and the existing :class:`ConsistLoco` is
202        returned. Fires :attr:`updated` on the consist so subscribers
203        can react to membership changes.
204        """
205        consist = self._consists.get(consist_address)
206        if consist is None:
207            raise KeyError(f"no consist at address {consist_address}")
208        for loco in consist.locos:
209            if loco.address == member_address:
210                loco.direction_normal = direction_normal
211                await self._after_membership_change(consist)
212                if consist.kind == "advanced":
213                    await self._program_cv19(
214                        member_address, long_address=loco.long_address,
215                        consist_address=consist_address,
216                        direction_normal=direction_normal,
217                    )
218                await self.updated.emit(consist)
219                return loco
220        loco = ConsistLoco(
221            address=member_address,
222            long_address=long_address,
223            direction_normal=direction_normal,
224            position=len(consist.locos),
225        )
226        consist.locos.append(loco)
227        await self._after_membership_change(consist)
228        if consist.kind == "advanced":
229            await self._program_cv19(
230                member_address, long_address=long_address,
231                consist_address=consist_address,
232                direction_normal=direction_normal,
233            )
234        await self.updated.emit(consist)
235        return loco

Add member_address to the consist at consist_address.

The member is appended at the end. If a loco with the same address already exists in the consist, its direction flag is updated in place and the existing ConsistLoco is returned. Fires updated on the consist so subscribers can react to membership changes.

async def remove_member(self, consist_address: int, member_address: int) -> bool:
310    async def remove_member(
311        self, consist_address: int, member_address: int,
312    ) -> bool:
313        """Drop *member_address* from the consist. Returns True if found.
314
315        Re-numbers surviving members' ``position`` fields so they stay
316        contiguous — the ordering is used for lead-loco / rear-loco
317        semantics in DCC-EX and NCE displays.
318        """
319        consist = self._consists.get(consist_address)
320        if consist is None:
321            return False
322        removed = False
323        removed_long_address = False
324        surviving: list[ConsistLoco] = []
325        for loco in consist.locos:
326            if loco.address == member_address and not removed:
327                removed = True
328                # Capture before the loco leaves — the ops programmer
329                # for the CV19 clear is keyed by the decoder's real
330                # address form (short vs long).
331                removed_long_address = loco.long_address
332                continue
333            surviving.append(loco)
334        if not removed:
335            return False
336        # Renumber positions.
337        for i, loco in enumerate(surviving):
338            loco.position = i
339        consist.locos = surviving
340        await self._after_membership_change(consist)
341        if consist.kind == "advanced":
342            await self._clear_cv19(
343                member_address, long_address=removed_long_address,
344                consist_address=consist_address,
345            )
346        await self.updated.emit(consist)
347        return True

Drop member_address from the consist. Returns True if found.

Re-numbers surviving members' position fields so they stay contiguous — the ordering is used for lead-loco / rear-loco semantics in DCC-EX and NCE displays.

async def set_speed(self, consist_address: int, speed: float) -> None:
351    async def set_speed(self, consist_address: int, speed: float) -> None:
352        """Fan a speed command to every member throttle.
353
354        Members with ``direction_normal=False`` are treated identically
355        for speed (only direction is inverted). Missing throttles
356        (member never acquired) are silently acquired first so the
357        first speed command on a fresh consist works.
358
359        Records the commanded speed on the manager and emits ``updated``
360        so any subscriber (the /consists page's WebSocket bus in
361        particular) re-renders the card with the slider at the new
362        position instead of snapping back to zero.
363        """
364        consist = self._require(consist_address)
365        for member in consist.locos:
366            throttle = await self._throttles.acquire(
367                member.address, long_address=member.long_address,
368            )
369            await throttle.set_speed(speed)
370        self._speeds[consist_address] = speed
371        await self.updated.emit(consist)

Fan a speed command to every member throttle.

Members with direction_normal=False are treated identically for speed (only direction is inverted). Missing throttles (member never acquired) are silently acquired first so the first speed command on a fresh consist works.

Records the commanded speed on the manager and emits updated so any subscriber (the /consists page's WebSocket bus in particular) re-renders the card with the slider at the new position instead of snapping back to zero.

async def set_direction( self, consist_address: int, direction: jmri_core.state.ThrottleDirection) -> None:
373    async def set_direction(
374        self, consist_address: int, direction: ThrottleDirection,
375    ) -> None:
376        """Fan a direction command to every member, honouring per-loco
377        orientation.
378
379        A member with ``direction_normal=False`` is oriented backwards
380        in the consist, so it receives the *opposite* direction to keep
381        the whole train moving the same way — this is the core reason
382        consists exist at the software level.
383        """
384        consist = self._require(consist_address)
385        opposite = (
386            ThrottleDirection.REVERSE
387            if direction is ThrottleDirection.FORWARD
388            else ThrottleDirection.FORWARD
389        )
390        for member in consist.locos:
391            throttle = await self._throttles.acquire(
392                member.address, long_address=member.long_address,
393            )
394            wire = direction if member.direction_normal else opposite
395            await throttle.set_direction(wire)
396        self._directions[consist_address] = direction
397        await self.updated.emit(consist)

Fan a direction command to every member, honouring per-loco orientation.

A member with direction_normal=False is oriented backwards in the consist, so it receives the opposite direction to keep the whole train moving the same way — this is the core reason consists exist at the software level.

async def set_function(self, consist_address: int, fn: int, on: bool) -> None:
399    async def set_function(
400        self, consist_address: int, fn: int, on: bool,
401    ) -> None:
402        """Fan a function command to every member.
403
404        JMRI's convention is that all lit functions (F0/headlight, F1/
405        bell, F2/horn) fire on every loco; layout-specific automation
406        that wants function isolation should drive individual throttles
407        directly.
408        """
409        consist = self._require(consist_address)
410        for member in consist.locos:
411            throttle = await self._throttles.acquire(
412                member.address, long_address=member.long_address,
413            )
414            await throttle.set_function(fn, on)

Fan a function command to every member.

JMRI's convention is that all lit functions (F0/headlight, F1/ bell, F2/horn) fire on every loco; layout-specific automation that wants function isolation should drive individual throttles directly.

async def emergency_stop(self, consist_address: int) -> None:
416    async def emergency_stop(self, consist_address: int) -> None:
417        """Fan an emergency-stop to every member throttle."""
418        consist = self._require(consist_address)
419        for member in consist.locos:
420            throttle = await self._throttles.acquire(
421                member.address, long_address=member.long_address,
422            )
423            await throttle.emergency_stop()
424        # Reset UI speed so the slider snaps back to 0 after an e-stop
425        # rather than staying at whatever the user last commanded.
426        self._speeds[consist_address] = 0.0
427        await self.updated.emit(consist)

Fan an emergency-stop to every member throttle.