jmri_nce.consist

NceConsistManager — hardware consist manager for NCE PowerPro.

NCE tracks command-station consists internally (1..127, six-member max: one LEAD + one REAR + up to four MID). The CS handles speed and direction fan-out on its own once a consist is built; we send add / remove commands using the existing 0xA2 loco command with consist-specific sub-command bytes.

Wire ops (all sub-commands of OP_NCE_LOCO_CMD = 0xA2 — see jmri_nce.opcodes for the numeric constants):

  • LOCO_SUB_CS_ADD_LEAD_FWD / _REV — install the first (lead) loco.
  • LOCO_SUB_CS_ADD_REAR_FWD / _REV — install the second (rear) loco.
  • LOCO_SUB_CS_ADD_MID_FWD / _REV — install a mid loco (positions 3..6).
  • LOCO_SUB_CS_REMOVE — remove a mid loco. LEAD/REAR are rejected by the CS; the manager guards this at the bean level.
  • LOCO_SUB_CS_KILL — dispose the whole consist; sent using the LEAD's DCC address so the CS knows which consist to tear down.

All values were cross-checked against JMRI Java jmri.jmrix.nce.NceConsist (add(), removeLocoFromConsist, killConsist) before being committed here — see the notes in jmri_nce.opcodes.

Semantic constraints:

  • Address range 1..127 — matches CONSIST_ADDRESS_MAX (already enforced by the Consist model).
  • Size limit 6 members — enforced here so add-past-six raises a ValueError that surfaces to the UI as a 400 rather than a silent no-op.
  • LEAD and REAR can't be removed individually — the CS design requires either killing the whole consist or leaving them alone. remove_member rejects those positions with a clear error.
  • Direction is baked in at ADD time. Flipping a member's direction_normal after add doesn't reprogram the CS in this first pass; the workaround is delete + recreate. Consistent with JMRI Java's behaviour.

Base ~jmri_core.beans.consist_manager.ConsistManager remains usable for kind="software" consists on an NCE connection — those bypass the CS and fan out via throttle commands, useful when a caller wants inverted-direction followers (not supported by NCE CS consists in this pass) or more than six members.

  1"""``NceConsistManager`` — hardware consist manager for NCE PowerPro.
  2
  3NCE tracks command-station consists internally (1..127, six-member max:
  4one LEAD + one REAR + up to four MID). The CS handles speed and
  5direction fan-out on its own once a consist is built; we send add /
  6remove commands using the existing ``0xA2`` loco command with
  7consist-specific sub-command bytes.
  8
  9Wire ops (all sub-commands of ``OP_NCE_LOCO_CMD = 0xA2`` — see
 10:mod:`jmri_nce.opcodes` for the numeric constants):
 11
 12* ``LOCO_SUB_CS_ADD_LEAD_FWD / _REV`` — install the first (lead) loco.
 13* ``LOCO_SUB_CS_ADD_REAR_FWD / _REV`` — install the second (rear) loco.
 14* ``LOCO_SUB_CS_ADD_MID_FWD / _REV`` — install a mid loco (positions 3..6).
 15* ``LOCO_SUB_CS_REMOVE`` — remove a *mid* loco. LEAD/REAR are rejected
 16  by the CS; the manager guards this at the bean level.
 17* ``LOCO_SUB_CS_KILL`` — dispose the whole consist; sent using the
 18  LEAD's DCC address so the CS knows which consist to tear down.
 19
 20All values were cross-checked against JMRI Java ``jmri.jmrix.nce.NceConsist``
 21(``add()``, ``removeLocoFromConsist``, ``killConsist``) before being
 22committed here — see the notes in :mod:`jmri_nce.opcodes`.
 23
 24Semantic constraints:
 25
 26* **Address range 1..127** — matches ``CONSIST_ADDRESS_MAX`` (already
 27  enforced by the ``Consist`` model).
 28* **Size limit 6 members** — enforced here so add-past-six raises a
 29  ``ValueError`` that surfaces to the UI as a 400 rather than a silent
 30  no-op.
 31* **LEAD and REAR can't be removed individually** — the CS design
 32  requires either killing the whole consist or leaving them alone.
 33  ``remove_member`` rejects those positions with a clear error.
 34* **Direction is baked in at ADD time**. Flipping a member's
 35  ``direction_normal`` after add doesn't reprogram the CS in this
 36  first pass; the workaround is delete + recreate. Consistent with
 37  JMRI Java's behaviour.
 38
 39Base :class:`~jmri_core.beans.consist_manager.ConsistManager` remains
 40usable for ``kind="software"`` consists on an NCE connection — those
 41bypass the CS and fan out via throttle commands, useful when a caller
 42wants inverted-direction followers (not supported by NCE CS consists in
 43this pass) or more than six members.
 44"""
 45from __future__ import annotations
 46
 47import logging
 48from typing import TYPE_CHECKING
 49
 50from jmri_core.beans.consist import Consist
 51from jmri_core.beans.consist_manager import ConsistManager
 52from jmri_core.beans.throttle_manager import ThrottleManager
 53from jmri_core.state import ThrottleDirection
 54
 55from jmri_nce.messages import nce_loco_cmd
 56from jmri_nce.opcodes import (
 57    LOCO_SUB_CS_ADD_LEAD_FWD,
 58    LOCO_SUB_CS_ADD_LEAD_REV,
 59    LOCO_SUB_CS_ADD_MID_FWD,
 60    LOCO_SUB_CS_ADD_MID_REV,
 61    LOCO_SUB_CS_ADD_REAR_FWD,
 62    LOCO_SUB_CS_ADD_REAR_REV,
 63    LOCO_SUB_CS_KILL,
 64    LOCO_SUB_CS_REMOVE,
 65)
 66
 67if TYPE_CHECKING:
 68    from jmri_core.beans.programmer import ProgrammerManager
 69
 70    from jmri_nce.traffic import NceTrafficController
 71
 72_log = logging.getLogger(__name__)
 73
 74# CS-managed consists on NCE PowerPro top out at six members (one LEAD,
 75# one REAR, up to four MIDs). Enforced in ``add_member`` — the CS would
 76# silently drop the seventh anyway but a clean error surfaces to the UI.
 77NCE_CS_CONSIST_SIZE_LIMIT = 6
 78
 79
 80class NceConsistManager(ConsistManager):
 81    """NCE consist manager. Default kind is ``command_station``.
 82
 83    Software consists still work if the caller asks for
 84    ``kind="software"`` at create time — the base fan-out then drives
 85    each member's throttle directly. Advanced consists (``kind="advanced"``)
 86    also still work, going through the base's CV19 ops-mode write.
 87    """
 88
 89    default_kind = "command_station"
 90
 91    def __init__(
 92        self,
 93        throttles: ThrottleManager,
 94        system_prefix: str,
 95        programmers: ProgrammerManager | None = None,
 96        *,
 97        tc: NceTrafficController,
 98    ) -> None:
 99        super().__init__(throttles, system_prefix, programmers)
100        self._tc = tc
101        # Members already installed on the CS side for each consist,
102        # keyed by consist address. Compared against ``consist.locos``
103        # in :meth:`_after_membership_change` to compute add / remove
104        # deltas. A member's tuple carries ``(address, long_address,
105        # direction_normal)`` so we can rebuild the exact ADD command
106        # if we ever need to re-send after a kill.
107        self._on_cs: dict[int, list[tuple[int, bool, bool]]] = {}
108
109    # ---- overrides ------------------------------------------------------
110
111    async def add_member(
112        self,
113        consist_address: int,
114        member_address: int,
115        *,
116        long_address: bool = False,
117        direction_normal: bool = True,
118    ) -> object:
119        """Enforce the CS-consist size cap before delegating to the
120        base add. Software consists on an NCE connection bypass the
121        cap since they don't touch the CS.
122        """
123        consist = self._consists.get(consist_address)
124        if (
125            consist is not None
126            and consist.kind == "command_station"
127            and not self._is_existing_member(consist, member_address)
128            and len(consist.locos) >= NCE_CS_CONSIST_SIZE_LIMIT
129        ):
130            raise ValueError(
131                f"NCE CS consist {consist_address} is full "
132                f"({NCE_CS_CONSIST_SIZE_LIMIT} members max); "
133                f"remove a member or delete the consist first"
134            )
135        return await super().add_member(
136            consist_address, member_address,
137            long_address=long_address, direction_normal=direction_normal,
138        )
139
140    async def remove_member(
141        self, consist_address: int, member_address: int,
142    ) -> bool:
143        """Reject LEAD (position 0) and REAR (position 1) removals for
144        CS-managed consists — the CS silently ignores those anyway,
145        and the JMRI Java implementation rejects them at the bean
146        level for the same reason. The escape hatch is to delete the
147        whole consist, which sends KILL.
148        """
149        consist = self._consists.get(consist_address)
150        if consist is not None and consist.kind == "command_station":
151            for loco in consist.locos:
152                if loco.address == member_address and loco.position < 2:
153                    role = "LEAD" if loco.position == 0 else "REAR"
154                    raise ValueError(
155                        f"cannot remove {role} loco {member_address} from "
156                        f"NCE CS consist {consist_address}; "
157                        f"delete the consist to disband it"
158                    )
159        return await super().remove_member(consist_address, member_address)
160
161    async def _after_membership_change(self, consist: Consist) -> None:
162        """Diff the CS-side member list against the consist's current
163        locos and send the ADD / REMOVE deltas.
164
165        Direction is baked in at ADD time — flipping ``direction_normal``
166        on an already-added member does *not* re-send the add. To change
167        orientation, remove the member (only MID positions are removable)
168        and re-add, or dispose the whole consist and rebuild.
169        """
170        if consist.kind != "command_station":
171            return
172        cs_list = self._on_cs.setdefault(consist.address, [])
173        cs_addrs = {t[0] for t in cs_list}
174        want_addrs = {m.address for m in consist.locos}
175
176        # Adds first, then removes. On adds, position is inferred from
177        # the member's index in ``consist.locos`` — the base's
178        # ``add_member`` sets ``position`` to the pre-append length, so
179        # it aligns with our LEAD/REAR/MID sub-command mapping.
180        for member in consist.locos:
181            if member.address in cs_addrs:
182                continue
183            await self._send_add(
184                consist.address, member.address,
185                long_address=member.long_address,
186                direction_normal=member.direction_normal,
187                position=member.position,
188            )
189            cs_list.append((
190                member.address, member.long_address, member.direction_normal,
191            ))
192
193        # Removes: anything on the CS side but not in the desired
194        # membership. In practice only MID positions ever land here
195        # because the ``remove_member`` override blocks LEAD/REAR.
196        stale = [t for t in cs_list if t[0] not in want_addrs]
197        for addr, long_addr, _dir in stale:
198            await self._send_remove(addr, long_addr)
199            cs_list.remove((addr, long_addr, _dir))
200
201    async def _before_delete(self, consist: Consist) -> None:
202        """Send KILL to the LEAD address so the CS wipes its
203        consist-table entry for this consist. If there's no LEAD (the
204        consist was empty), there's nothing to kill — just drop our
205        bookkeeping."""
206        if consist.kind != "command_station":
207            return
208        cs_list = self._on_cs.pop(consist.address, None)
209        if not cs_list:
210            return
211        lead_addr, lead_long, _dir = cs_list[0]
212        await self._send_kill(lead_addr, lead_long)
213
214    # ---- fan-out overrides — CS handles speed / direction internally ----
215
216    async def set_speed(self, consist_address: int, speed: float) -> None:
217        """Send to the LEAD only — the CS fans out. Falls through to
218        the base per-loco fan-out for ``kind="software"``.
219        """
220        consist = self._require(consist_address)
221        if consist.kind != "command_station":
222            await super().set_speed(consist_address, speed)
223            return
224        if not consist.locos:
225            return
226        lead = consist.locos[0]
227        throttle = await self._throttles.acquire(
228            lead.address, long_address=lead.long_address,
229        )
230        await throttle.set_speed(speed)
231        # Preserve the UI-side "last commanded" state that the base
232        # tracks — the /consists slider reads this back on re-render.
233        self._speeds[consist_address] = speed
234        await self.updated.emit(consist)
235
236    async def set_direction(
237        self, consist_address: int, direction: ThrottleDirection,
238    ) -> None:
239        """Send to the LEAD only. Per-loco orientation is already baked
240        in on each member's slot (from the ADD command's FWD/REV
241        variant) so flipping the whole consist's direction here is a
242        single-address operation."""
243        consist = self._require(consist_address)
244        if consist.kind != "command_station":
245            await super().set_direction(consist_address, direction)
246            return
247        if not consist.locos:
248            return
249        lead = consist.locos[0]
250        throttle = await self._throttles.acquire(
251            lead.address, long_address=lead.long_address,
252        )
253        await throttle.set_direction(direction)
254        self._directions[consist_address] = direction
255        await self.updated.emit(consist)
256
257    async def emergency_stop(self, consist_address: int) -> None:
258        """E-stop the LEAD; CS fans out. Belt-and-braces: we also
259        e-stop every member throttle individually so a decoder that's
260        momentarily out of sync with the CS still stops (matches JMRI
261        Java's ``NceConsistManager`` e-stop behaviour)."""
262        consist = self._require(consist_address)
263        if consist.kind != "command_station":
264            await super().emergency_stop(consist_address)
265            return
266        for member in consist.locos:
267            throttle = await self._throttles.acquire(
268                member.address, long_address=member.long_address,
269            )
270            await throttle.emergency_stop()
271        self._speeds[consist_address] = 0.0
272        await self.updated.emit(consist)
273
274    # ``set_function`` intentionally *not* overridden. NCE CS consists,
275    # like LocoNet's, don't fan functions — the base's per-loco loop is
276    # exactly what a user wants (a headlight command should hit every
277    # loco separately).
278
279    # ---- wire helpers ---------------------------------------------------
280
281    async def _send_add(
282        self,
283        consist_address: int,
284        member_address: int,
285        *,
286        long_address: bool,
287        direction_normal: bool,
288        position: int,
289    ) -> None:
290        """Send the appropriate ``0xA2`` sub-command based on position
291        and direction. Position 0 → LEAD, 1 → REAR, else MID."""
292        if position == 0:
293            sub = (
294                LOCO_SUB_CS_ADD_LEAD_FWD if direction_normal
295                else LOCO_SUB_CS_ADD_LEAD_REV
296            )
297            role = "LEAD"
298        elif position == 1:
299            sub = (
300                LOCO_SUB_CS_ADD_REAR_FWD if direction_normal
301                else LOCO_SUB_CS_ADD_REAR_REV
302            )
303            role = "REAR"
304        else:
305            sub = (
306                LOCO_SUB_CS_ADD_MID_FWD if direction_normal
307                else LOCO_SUB_CS_ADD_MID_REV
308            )
309            role = f"MID#{position - 1}"
310        cmd = nce_loco_cmd(
311            member_address,
312            long_address=long_address,
313            sub_cmd=sub,
314            data=consist_address,
315        )
316        _log.info(
317            "NCE consist %d: ADD %s addr=%d%s dir=%s (sub=0x%02X)",
318            consist_address, role, member_address,
319            "L" if long_address else "S",
320            "FWD" if direction_normal else "REV",
321            sub,
322        )
323        await self._tc.send_command(cmd)
324
325    async def _send_remove(
326        self, member_address: int, long_address: bool,
327    ) -> None:
328        cmd = nce_loco_cmd(
329            member_address,
330            long_address=long_address,
331            sub_cmd=LOCO_SUB_CS_REMOVE,
332            data=0,
333        )
334        _log.info(
335            "NCE consist: REMOVE addr=%d%s",
336            member_address, "L" if long_address else "S",
337        )
338        await self._tc.send_command(cmd)
339
340    async def _send_kill(
341        self, lead_address: int, lead_long_address: bool,
342    ) -> None:
343        cmd = nce_loco_cmd(
344            lead_address,
345            long_address=lead_long_address,
346            sub_cmd=LOCO_SUB_CS_KILL,
347            data=0,
348        )
349        _log.info(
350            "NCE consist: KILL via LEAD addr=%d%s",
351            lead_address, "L" if lead_long_address else "S",
352        )
353        await self._tc.send_command(cmd)
354
355    def _is_existing_member(self, consist: Consist, address: int) -> bool:
356        return any(loco.address == address for loco in consist.locos)
NCE_CS_CONSIST_SIZE_LIMIT = 6
class NceConsistManager(jmri_core.beans.consist_manager.ConsistManager):
 81class NceConsistManager(ConsistManager):
 82    """NCE consist manager. Default kind is ``command_station``.
 83
 84    Software consists still work if the caller asks for
 85    ``kind="software"`` at create time — the base fan-out then drives
 86    each member's throttle directly. Advanced consists (``kind="advanced"``)
 87    also still work, going through the base's CV19 ops-mode write.
 88    """
 89
 90    default_kind = "command_station"
 91
 92    def __init__(
 93        self,
 94        throttles: ThrottleManager,
 95        system_prefix: str,
 96        programmers: ProgrammerManager | None = None,
 97        *,
 98        tc: NceTrafficController,
 99    ) -> None:
100        super().__init__(throttles, system_prefix, programmers)
101        self._tc = tc
102        # Members already installed on the CS side for each consist,
103        # keyed by consist address. Compared against ``consist.locos``
104        # in :meth:`_after_membership_change` to compute add / remove
105        # deltas. A member's tuple carries ``(address, long_address,
106        # direction_normal)`` so we can rebuild the exact ADD command
107        # if we ever need to re-send after a kill.
108        self._on_cs: dict[int, list[tuple[int, bool, bool]]] = {}
109
110    # ---- overrides ------------------------------------------------------
111
112    async def add_member(
113        self,
114        consist_address: int,
115        member_address: int,
116        *,
117        long_address: bool = False,
118        direction_normal: bool = True,
119    ) -> object:
120        """Enforce the CS-consist size cap before delegating to the
121        base add. Software consists on an NCE connection bypass the
122        cap since they don't touch the CS.
123        """
124        consist = self._consists.get(consist_address)
125        if (
126            consist is not None
127            and consist.kind == "command_station"
128            and not self._is_existing_member(consist, member_address)
129            and len(consist.locos) >= NCE_CS_CONSIST_SIZE_LIMIT
130        ):
131            raise ValueError(
132                f"NCE CS consist {consist_address} is full "
133                f"({NCE_CS_CONSIST_SIZE_LIMIT} members max); "
134                f"remove a member or delete the consist first"
135            )
136        return await super().add_member(
137            consist_address, member_address,
138            long_address=long_address, direction_normal=direction_normal,
139        )
140
141    async def remove_member(
142        self, consist_address: int, member_address: int,
143    ) -> bool:
144        """Reject LEAD (position 0) and REAR (position 1) removals for
145        CS-managed consists — the CS silently ignores those anyway,
146        and the JMRI Java implementation rejects them at the bean
147        level for the same reason. The escape hatch is to delete the
148        whole consist, which sends KILL.
149        """
150        consist = self._consists.get(consist_address)
151        if consist is not None and consist.kind == "command_station":
152            for loco in consist.locos:
153                if loco.address == member_address and loco.position < 2:
154                    role = "LEAD" if loco.position == 0 else "REAR"
155                    raise ValueError(
156                        f"cannot remove {role} loco {member_address} from "
157                        f"NCE CS consist {consist_address}; "
158                        f"delete the consist to disband it"
159                    )
160        return await super().remove_member(consist_address, member_address)
161
162    async def _after_membership_change(self, consist: Consist) -> None:
163        """Diff the CS-side member list against the consist's current
164        locos and send the ADD / REMOVE deltas.
165
166        Direction is baked in at ADD time — flipping ``direction_normal``
167        on an already-added member does *not* re-send the add. To change
168        orientation, remove the member (only MID positions are removable)
169        and re-add, or dispose the whole consist and rebuild.
170        """
171        if consist.kind != "command_station":
172            return
173        cs_list = self._on_cs.setdefault(consist.address, [])
174        cs_addrs = {t[0] for t in cs_list}
175        want_addrs = {m.address for m in consist.locos}
176
177        # Adds first, then removes. On adds, position is inferred from
178        # the member's index in ``consist.locos`` — the base's
179        # ``add_member`` sets ``position`` to the pre-append length, so
180        # it aligns with our LEAD/REAR/MID sub-command mapping.
181        for member in consist.locos:
182            if member.address in cs_addrs:
183                continue
184            await self._send_add(
185                consist.address, member.address,
186                long_address=member.long_address,
187                direction_normal=member.direction_normal,
188                position=member.position,
189            )
190            cs_list.append((
191                member.address, member.long_address, member.direction_normal,
192            ))
193
194        # Removes: anything on the CS side but not in the desired
195        # membership. In practice only MID positions ever land here
196        # because the ``remove_member`` override blocks LEAD/REAR.
197        stale = [t for t in cs_list if t[0] not in want_addrs]
198        for addr, long_addr, _dir in stale:
199            await self._send_remove(addr, long_addr)
200            cs_list.remove((addr, long_addr, _dir))
201
202    async def _before_delete(self, consist: Consist) -> None:
203        """Send KILL to the LEAD address so the CS wipes its
204        consist-table entry for this consist. If there's no LEAD (the
205        consist was empty), there's nothing to kill — just drop our
206        bookkeeping."""
207        if consist.kind != "command_station":
208            return
209        cs_list = self._on_cs.pop(consist.address, None)
210        if not cs_list:
211            return
212        lead_addr, lead_long, _dir = cs_list[0]
213        await self._send_kill(lead_addr, lead_long)
214
215    # ---- fan-out overrides — CS handles speed / direction internally ----
216
217    async def set_speed(self, consist_address: int, speed: float) -> None:
218        """Send to the LEAD only — the CS fans out. Falls through to
219        the base per-loco fan-out for ``kind="software"``.
220        """
221        consist = self._require(consist_address)
222        if consist.kind != "command_station":
223            await super().set_speed(consist_address, speed)
224            return
225        if not consist.locos:
226            return
227        lead = consist.locos[0]
228        throttle = await self._throttles.acquire(
229            lead.address, long_address=lead.long_address,
230        )
231        await throttle.set_speed(speed)
232        # Preserve the UI-side "last commanded" state that the base
233        # tracks — the /consists slider reads this back on re-render.
234        self._speeds[consist_address] = speed
235        await self.updated.emit(consist)
236
237    async def set_direction(
238        self, consist_address: int, direction: ThrottleDirection,
239    ) -> None:
240        """Send to the LEAD only. Per-loco orientation is already baked
241        in on each member's slot (from the ADD command's FWD/REV
242        variant) so flipping the whole consist's direction here is a
243        single-address operation."""
244        consist = self._require(consist_address)
245        if consist.kind != "command_station":
246            await super().set_direction(consist_address, direction)
247            return
248        if not consist.locos:
249            return
250        lead = consist.locos[0]
251        throttle = await self._throttles.acquire(
252            lead.address, long_address=lead.long_address,
253        )
254        await throttle.set_direction(direction)
255        self._directions[consist_address] = direction
256        await self.updated.emit(consist)
257
258    async def emergency_stop(self, consist_address: int) -> None:
259        """E-stop the LEAD; CS fans out. Belt-and-braces: we also
260        e-stop every member throttle individually so a decoder that's
261        momentarily out of sync with the CS still stops (matches JMRI
262        Java's ``NceConsistManager`` e-stop behaviour)."""
263        consist = self._require(consist_address)
264        if consist.kind != "command_station":
265            await super().emergency_stop(consist_address)
266            return
267        for member in consist.locos:
268            throttle = await self._throttles.acquire(
269                member.address, long_address=member.long_address,
270            )
271            await throttle.emergency_stop()
272        self._speeds[consist_address] = 0.0
273        await self.updated.emit(consist)
274
275    # ``set_function`` intentionally *not* overridden. NCE CS consists,
276    # like LocoNet's, don't fan functions — the base's per-loco loop is
277    # exactly what a user wants (a headlight command should hit every
278    # loco separately).
279
280    # ---- wire helpers ---------------------------------------------------
281
282    async def _send_add(
283        self,
284        consist_address: int,
285        member_address: int,
286        *,
287        long_address: bool,
288        direction_normal: bool,
289        position: int,
290    ) -> None:
291        """Send the appropriate ``0xA2`` sub-command based on position
292        and direction. Position 0 → LEAD, 1 → REAR, else MID."""
293        if position == 0:
294            sub = (
295                LOCO_SUB_CS_ADD_LEAD_FWD if direction_normal
296                else LOCO_SUB_CS_ADD_LEAD_REV
297            )
298            role = "LEAD"
299        elif position == 1:
300            sub = (
301                LOCO_SUB_CS_ADD_REAR_FWD if direction_normal
302                else LOCO_SUB_CS_ADD_REAR_REV
303            )
304            role = "REAR"
305        else:
306            sub = (
307                LOCO_SUB_CS_ADD_MID_FWD if direction_normal
308                else LOCO_SUB_CS_ADD_MID_REV
309            )
310            role = f"MID#{position - 1}"
311        cmd = nce_loco_cmd(
312            member_address,
313            long_address=long_address,
314            sub_cmd=sub,
315            data=consist_address,
316        )
317        _log.info(
318            "NCE consist %d: ADD %s addr=%d%s dir=%s (sub=0x%02X)",
319            consist_address, role, member_address,
320            "L" if long_address else "S",
321            "FWD" if direction_normal else "REV",
322            sub,
323        )
324        await self._tc.send_command(cmd)
325
326    async def _send_remove(
327        self, member_address: int, long_address: bool,
328    ) -> None:
329        cmd = nce_loco_cmd(
330            member_address,
331            long_address=long_address,
332            sub_cmd=LOCO_SUB_CS_REMOVE,
333            data=0,
334        )
335        _log.info(
336            "NCE consist: REMOVE addr=%d%s",
337            member_address, "L" if long_address else "S",
338        )
339        await self._tc.send_command(cmd)
340
341    async def _send_kill(
342        self, lead_address: int, lead_long_address: bool,
343    ) -> None:
344        cmd = nce_loco_cmd(
345            lead_address,
346            long_address=lead_long_address,
347            sub_cmd=LOCO_SUB_CS_KILL,
348            data=0,
349        )
350        _log.info(
351            "NCE consist: KILL via LEAD addr=%d%s",
352            lead_address, "L" if lead_long_address else "S",
353        )
354        await self._tc.send_command(cmd)
355
356    def _is_existing_member(self, consist: Consist, address: int) -> bool:
357        return any(loco.address == address for loco in consist.locos)

NCE consist manager. Default kind is command_station.

Software consists still work if the caller asks for kind="software" at create time — the base fan-out then drives each member's throttle directly. Advanced consists (kind="advanced") also still work, going through the base's CV19 ops-mode write.

NceConsistManager( throttles: jmri_core.beans.ThrottleManager, system_prefix: str, programmers: jmri_core.beans.ProgrammerManager | None = None, *, tc: jmri_nce.NceTrafficController)
 92    def __init__(
 93        self,
 94        throttles: ThrottleManager,
 95        system_prefix: str,
 96        programmers: ProgrammerManager | None = None,
 97        *,
 98        tc: NceTrafficController,
 99    ) -> None:
100        super().__init__(throttles, system_prefix, programmers)
101        self._tc = tc
102        # Members already installed on the CS side for each consist,
103        # keyed by consist address. Compared against ``consist.locos``
104        # in :meth:`_after_membership_change` to compute add / remove
105        # deltas. A member's tuple carries ``(address, long_address,
106        # direction_normal)`` so we can rebuild the exact ADD command
107        # if we ever need to re-send after a kill.
108        self._on_cs: dict[int, list[tuple[int, bool, bool]]] = {}
default_kind = 'command_station'
async def add_member( self, consist_address: int, member_address: int, *, long_address: bool = False, direction_normal: bool = True) -> object:
112    async def add_member(
113        self,
114        consist_address: int,
115        member_address: int,
116        *,
117        long_address: bool = False,
118        direction_normal: bool = True,
119    ) -> object:
120        """Enforce the CS-consist size cap before delegating to the
121        base add. Software consists on an NCE connection bypass the
122        cap since they don't touch the CS.
123        """
124        consist = self._consists.get(consist_address)
125        if (
126            consist is not None
127            and consist.kind == "command_station"
128            and not self._is_existing_member(consist, member_address)
129            and len(consist.locos) >= NCE_CS_CONSIST_SIZE_LIMIT
130        ):
131            raise ValueError(
132                f"NCE CS consist {consist_address} is full "
133                f"({NCE_CS_CONSIST_SIZE_LIMIT} members max); "
134                f"remove a member or delete the consist first"
135            )
136        return await super().add_member(
137            consist_address, member_address,
138            long_address=long_address, direction_normal=direction_normal,
139        )

Enforce the CS-consist size cap before delegating to the base add. Software consists on an NCE connection bypass the cap since they don't touch the CS.

async def remove_member(self, consist_address: int, member_address: int) -> bool:
141    async def remove_member(
142        self, consist_address: int, member_address: int,
143    ) -> bool:
144        """Reject LEAD (position 0) and REAR (position 1) removals for
145        CS-managed consists — the CS silently ignores those anyway,
146        and the JMRI Java implementation rejects them at the bean
147        level for the same reason. The escape hatch is to delete the
148        whole consist, which sends KILL.
149        """
150        consist = self._consists.get(consist_address)
151        if consist is not None and consist.kind == "command_station":
152            for loco in consist.locos:
153                if loco.address == member_address and loco.position < 2:
154                    role = "LEAD" if loco.position == 0 else "REAR"
155                    raise ValueError(
156                        f"cannot remove {role} loco {member_address} from "
157                        f"NCE CS consist {consist_address}; "
158                        f"delete the consist to disband it"
159                    )
160        return await super().remove_member(consist_address, member_address)

Reject LEAD (position 0) and REAR (position 1) removals for CS-managed consists — the CS silently ignores those anyway, and the JMRI Java implementation rejects them at the bean level for the same reason. The escape hatch is to delete the whole consist, which sends KILL.

async def set_speed(self, consist_address: int, speed: float) -> None:
217    async def set_speed(self, consist_address: int, speed: float) -> None:
218        """Send to the LEAD only — the CS fans out. Falls through to
219        the base per-loco fan-out for ``kind="software"``.
220        """
221        consist = self._require(consist_address)
222        if consist.kind != "command_station":
223            await super().set_speed(consist_address, speed)
224            return
225        if not consist.locos:
226            return
227        lead = consist.locos[0]
228        throttle = await self._throttles.acquire(
229            lead.address, long_address=lead.long_address,
230        )
231        await throttle.set_speed(speed)
232        # Preserve the UI-side "last commanded" state that the base
233        # tracks — the /consists slider reads this back on re-render.
234        self._speeds[consist_address] = speed
235        await self.updated.emit(consist)

Send to the LEAD only — the CS fans out. Falls through to the base per-loco fan-out for kind="software".

async def set_direction( self, consist_address: int, direction: jmri_core.state.ThrottleDirection) -> None:
237    async def set_direction(
238        self, consist_address: int, direction: ThrottleDirection,
239    ) -> None:
240        """Send to the LEAD only. Per-loco orientation is already baked
241        in on each member's slot (from the ADD command's FWD/REV
242        variant) so flipping the whole consist's direction here is a
243        single-address operation."""
244        consist = self._require(consist_address)
245        if consist.kind != "command_station":
246            await super().set_direction(consist_address, direction)
247            return
248        if not consist.locos:
249            return
250        lead = consist.locos[0]
251        throttle = await self._throttles.acquire(
252            lead.address, long_address=lead.long_address,
253        )
254        await throttle.set_direction(direction)
255        self._directions[consist_address] = direction
256        await self.updated.emit(consist)

Send to the LEAD only. Per-loco orientation is already baked in on each member's slot (from the ADD command's FWD/REV variant) so flipping the whole consist's direction here is a single-address operation.

async def emergency_stop(self, consist_address: int) -> None:
258    async def emergency_stop(self, consist_address: int) -> None:
259        """E-stop the LEAD; CS fans out. Belt-and-braces: we also
260        e-stop every member throttle individually so a decoder that's
261        momentarily out of sync with the CS still stops (matches JMRI
262        Java's ``NceConsistManager`` e-stop behaviour)."""
263        consist = self._require(consist_address)
264        if consist.kind != "command_station":
265            await super().emergency_stop(consist_address)
266            return
267        for member in consist.locos:
268            throttle = await self._throttles.acquire(
269                member.address, long_address=member.long_address,
270            )
271            await throttle.emergency_stop()
272        self._speeds[consist_address] = 0.0
273        await self.updated.emit(consist)

E-stop the LEAD; CS fans out. Belt-and-braces: we also e-stop every member throttle individually so a decoder that's momentarily out of sync with the CS still stops (matches JMRI Java's NceConsistManager e-stop behaviour).