jmri_loconet.consist

LnConsistManager — hardware consist manager for LocoNet.

LocoNet consists are slot-linked: the command station tracks which slots belong to a group and, on speed and direction commands to the group's lead slot, broadcasts the same command to every follower slot automatically. Function commands stay per-loco because per-loco lighting is what the user wants.

Wire ops:

  • OPC_LINK_SLOTS(src, dest) — attach src as a follower of dest. Repeat for each additional follower.
  • OPC_UNLINK_SLOTS(src, dest) — detach src from dest.

We build on the software ~jmri_core.beans.consist_manager.ConsistManager so a script that only uses the public API works whether the connection is LocoNet, NCE, DCC-EX, or the internal loopback. Overrides:

  • _after_membership_change — reconciles the on-wire link set with the consist's current membership, sending LINK / UNLINK for the diff.
  • _before_delete — unlinks every follower so the consist tears down cleanly.
  • set_speed / set_direction / emergency_stop — send only to the lead throttle. The CS handles the fan-out via LINK, so sending to followers would be redundant (harmless, but wastes bandwidth on a slow serial bus).
  • set_function — falls through to the base's per-loco fan-out because LocoNet doesn't fan functions through LINK.
  1"""``LnConsistManager`` — hardware consist manager for LocoNet.
  2
  3LocoNet consists are **slot-linked**: the command station tracks which
  4slots belong to a group and, on speed and direction commands to the
  5group's *lead* slot, broadcasts the same command to every follower slot
  6automatically. Function commands stay per-loco because per-loco lighting
  7is what the user wants.
  8
  9Wire ops:
 10
 11* ``OPC_LINK_SLOTS(src, dest)`` — attach ``src`` as a follower of
 12  ``dest``. Repeat for each additional follower.
 13* ``OPC_UNLINK_SLOTS(src, dest)`` — detach ``src`` from ``dest``.
 14
 15We build on the software :class:`~jmri_core.beans.consist_manager.ConsistManager`
 16so a script that only uses the public API works whether the connection
 17is LocoNet, NCE, DCC-EX, or the internal loopback. Overrides:
 18
 19* ``_after_membership_change`` — reconciles the on-wire link set with
 20  the consist's current membership, sending ``LINK`` / ``UNLINK`` for
 21  the diff.
 22* ``_before_delete`` — unlinks every follower so the consist tears
 23  down cleanly.
 24* ``set_speed`` / ``set_direction`` / ``emergency_stop`` — send only
 25  to the *lead* throttle. The CS handles the fan-out via LINK, so
 26  sending to followers would be redundant (harmless, but wastes
 27  bandwidth on a slow serial bus).
 28* ``set_function`` — falls through to the base's per-loco fan-out
 29  because LocoNet doesn't fan functions through LINK.
 30"""
 31from __future__ import annotations
 32
 33import logging
 34from typing import TYPE_CHECKING
 35
 36from jmri_core.beans.consist import Consist
 37from jmri_core.beans.consist_manager import ConsistManager
 38from jmri_core.state import ThrottleDirection
 39
 40from jmri_loconet.messages import OpcLinkSlots, OpcUnlinkSlots
 41from jmri_loconet.throttle import LnThrottle, LnThrottleManager
 42
 43if TYPE_CHECKING:
 44    from jmri_loconet.slots import SlotManager
 45    from jmri_loconet.traffic import LocoNetTrafficController
 46
 47_log = logging.getLogger(__name__)
 48
 49
 50class LnConsistManager(ConsistManager):
 51    """LocoNet consist manager backed by ``OPC_LINK_SLOTS`` /
 52    ``OPC_UNLINK_SLOTS``.
 53
 54    Consists are ``command_station`` by default here — the CS is doing
 55    the fan-out, not us. If a caller explicitly creates a consist with
 56    ``kind="software"`` we still store it and drive it via base
 57    fan-out, so the two modes coexist.
 58    """
 59
 60    default_kind = "command_station"
 61
 62    def __init__(
 63        self,
 64        throttles: LnThrottleManager,
 65        system_prefix: str,
 66        *,
 67        tc: LocoNetTrafficController,
 68        slots: SlotManager,
 69    ) -> None:
 70        super().__init__(throttles, system_prefix)
 71        self._tc = tc
 72        self._slots = slots
 73        # Per-consist set of currently-linked (lead_addr, follower_addr)
 74        # pairs. Kept alongside the consist's own locos list so we can
 75        # diff on membership change and only issue LINK/UNLINK for the
 76        # deltas — repeatedly sending LINK for an already-linked pair
 77        # confuses some CS firmware versions.
 78        self._linked: dict[int, set[tuple[int, int]]] = {}
 79
 80    # ---- wire-level plumbing --------------------------------------------
 81
 82    async def _link_pair(self, lead_addr: int, follower_addr: int) -> None:
 83        """Acquire both throttles' slots and send ``OPC_LINK_SLOTS``.
 84
 85        Acquiring the throttle triggers the slot lookup + claim, so we
 86        can rely on ``LnThrottle.slot`` being populated afterward.
 87        """
 88        lead = await self._throttles.acquire(lead_addr)
 89        follower = await self._throttles.acquire(follower_addr)
 90        if not isinstance(lead, LnThrottle) or not isinstance(follower, LnThrottle):
 91            # Should not happen under a real LocoNet connection, but
 92            # keeps the type-narrowing honest.
 93            return
 94        await self._tc.send(OpcLinkSlots(
 95            src=follower.slot.number, dest=lead.slot.number,
 96        ))
 97        _log.info(
 98            "LINK slot %d as follower of %d (addr %d follows %d)",
 99            follower.slot.number, lead.slot.number,
100            follower_addr, lead_addr,
101        )
102
103    async def _unlink_pair(self, lead_addr: int, follower_addr: int) -> None:
104        """Send ``OPC_UNLINK_SLOTS`` for the pair.
105
106        The throttles must still hold their slots — we look them up
107        rather than acquire, because after unlink the follower is a
108        standalone slot again and re-acquiring would confuse the CS.
109        """
110        lead = self._throttles.get(lead_addr)
111        follower = self._throttles.get(follower_addr)
112        if not isinstance(lead, LnThrottle) or not isinstance(follower, LnThrottle):
113            return
114        await self._tc.send(OpcUnlinkSlots(
115            src=follower.slot.number, dest=lead.slot.number,
116        ))
117        _log.info(
118            "UNLINK slot %d from lead %d (addr %d ⇸ %d)",
119            follower.slot.number, lead.slot.number,
120            follower_addr, lead_addr,
121        )
122
123    # ---- ConsistManager overrides ---------------------------------------
124
125    async def _after_membership_change(self, consist: Consist) -> None:
126        """Diff the current wire-linked set against the consist's
127        membership and issue the LINK/UNLINK deltas.
128
129        The consist's first loco is always the lead. Every other member
130        must be LINKed to the lead's slot. Order of LINK ops matches
131        JMRI Java's behaviour: unlink stale followers first, then link
132        new ones — avoids the CS transiently seeing overlapping links.
133        """
134        if consist.kind == "software":
135            # Explicit software consist: don't touch the wire.
136            return
137        if not consist.locos:
138            return
139        lead_addr = consist.locos[0].address
140        desired: set[tuple[int, int]] = {
141            (lead_addr, m.address) for m in consist.locos[1:]
142        }
143        current = self._linked.setdefault(consist.address, set())
144        to_unlink = current - desired
145        to_link = desired - current
146        for lead, follower in to_unlink:
147            await self._unlink_pair(lead, follower)
148            current.discard((lead, follower))
149        for lead, follower in to_link:
150            await self._link_pair(lead, follower)
151            current.add((lead, follower))
152
153    async def _before_delete(self, consist: Consist) -> None:
154        """Unlink every follower before dropping the consist."""
155        if consist.kind == "software":
156            return
157        for lead_addr, follower_addr in list(
158            self._linked.get(consist.address, set())
159        ):
160            await self._unlink_pair(lead_addr, follower_addr)
161        self._linked.pop(consist.address, None)
162
163    async def set_speed(self, consist_address: int, speed: float) -> None:
164        """Send to the lead only — the CS fans via LINK.
165
166        For a ``kind="software"`` consist stored alongside CS-managed
167        ones, we still delegate to the base's per-loco fan-out.
168        """
169        consist = self._require(consist_address)
170        if consist.kind == "software":
171            await super().set_speed(consist_address, speed)
172            return
173        if not consist.locos:
174            return
175        lead = consist.locos[0]
176        throttle = await self._throttles.acquire(
177            lead.address, long_address=lead.long_address,
178        )
179        await throttle.set_speed(speed)
180
181    async def set_direction(
182        self, consist_address: int, direction: ThrottleDirection,
183    ) -> None:
184        """Send to the lead only — the CS fans via LINK.
185
186        Per-loco orientation for software consists is preserved by the
187        base class; for CS consists, the orientation was baked in at
188        LINK time on each follower's SLOT_STAT byte (bit 6) — outside
189        the scope of this initial pass. Callers wanting inverted
190        followers should for now use a ``software`` consist.
191        """
192        consist = self._require(consist_address)
193        if consist.kind == "software":
194            await super().set_direction(consist_address, direction)
195            return
196        if not consist.locos:
197            return
198        lead = consist.locos[0]
199        throttle = await self._throttles.acquire(
200            lead.address, long_address=lead.long_address,
201        )
202        await throttle.set_direction(direction)
203
204    async def emergency_stop(self, consist_address: int) -> None:
205        """Send e-stop to the lead — CS fans it out.
206
207        For extra safety we also fan to any acquired follower throttle
208        so a decoder that's momentarily out of sync with the CS still
209        stops. Matches JMRI Java's belt-and-braces approach on the
210        e-stop path.
211        """
212        consist = self._require(consist_address)
213        if consist.kind == "software":
214            await super().emergency_stop(consist_address)
215            return
216        for member in consist.locos:
217            throttle = await self._throttles.acquire(
218                member.address, long_address=member.long_address,
219            )
220            await throttle.emergency_stop()
221
222    # ``set_function`` intentionally *not* overridden: functions are per
223    # loco on LocoNet — LINK doesn't fan them — so the base's fan-out is
224    # exactly what we want.
class LnConsistManager(jmri_core.beans.consist_manager.ConsistManager):
 51class LnConsistManager(ConsistManager):
 52    """LocoNet consist manager backed by ``OPC_LINK_SLOTS`` /
 53    ``OPC_UNLINK_SLOTS``.
 54
 55    Consists are ``command_station`` by default here — the CS is doing
 56    the fan-out, not us. If a caller explicitly creates a consist with
 57    ``kind="software"`` we still store it and drive it via base
 58    fan-out, so the two modes coexist.
 59    """
 60
 61    default_kind = "command_station"
 62
 63    def __init__(
 64        self,
 65        throttles: LnThrottleManager,
 66        system_prefix: str,
 67        *,
 68        tc: LocoNetTrafficController,
 69        slots: SlotManager,
 70    ) -> None:
 71        super().__init__(throttles, system_prefix)
 72        self._tc = tc
 73        self._slots = slots
 74        # Per-consist set of currently-linked (lead_addr, follower_addr)
 75        # pairs. Kept alongside the consist's own locos list so we can
 76        # diff on membership change and only issue LINK/UNLINK for the
 77        # deltas — repeatedly sending LINK for an already-linked pair
 78        # confuses some CS firmware versions.
 79        self._linked: dict[int, set[tuple[int, int]]] = {}
 80
 81    # ---- wire-level plumbing --------------------------------------------
 82
 83    async def _link_pair(self, lead_addr: int, follower_addr: int) -> None:
 84        """Acquire both throttles' slots and send ``OPC_LINK_SLOTS``.
 85
 86        Acquiring the throttle triggers the slot lookup + claim, so we
 87        can rely on ``LnThrottle.slot`` being populated afterward.
 88        """
 89        lead = await self._throttles.acquire(lead_addr)
 90        follower = await self._throttles.acquire(follower_addr)
 91        if not isinstance(lead, LnThrottle) or not isinstance(follower, LnThrottle):
 92            # Should not happen under a real LocoNet connection, but
 93            # keeps the type-narrowing honest.
 94            return
 95        await self._tc.send(OpcLinkSlots(
 96            src=follower.slot.number, dest=lead.slot.number,
 97        ))
 98        _log.info(
 99            "LINK slot %d as follower of %d (addr %d follows %d)",
100            follower.slot.number, lead.slot.number,
101            follower_addr, lead_addr,
102        )
103
104    async def _unlink_pair(self, lead_addr: int, follower_addr: int) -> None:
105        """Send ``OPC_UNLINK_SLOTS`` for the pair.
106
107        The throttles must still hold their slots — we look them up
108        rather than acquire, because after unlink the follower is a
109        standalone slot again and re-acquiring would confuse the CS.
110        """
111        lead = self._throttles.get(lead_addr)
112        follower = self._throttles.get(follower_addr)
113        if not isinstance(lead, LnThrottle) or not isinstance(follower, LnThrottle):
114            return
115        await self._tc.send(OpcUnlinkSlots(
116            src=follower.slot.number, dest=lead.slot.number,
117        ))
118        _log.info(
119            "UNLINK slot %d from lead %d (addr %d ⇸ %d)",
120            follower.slot.number, lead.slot.number,
121            follower_addr, lead_addr,
122        )
123
124    # ---- ConsistManager overrides ---------------------------------------
125
126    async def _after_membership_change(self, consist: Consist) -> None:
127        """Diff the current wire-linked set against the consist's
128        membership and issue the LINK/UNLINK deltas.
129
130        The consist's first loco is always the lead. Every other member
131        must be LINKed to the lead's slot. Order of LINK ops matches
132        JMRI Java's behaviour: unlink stale followers first, then link
133        new ones — avoids the CS transiently seeing overlapping links.
134        """
135        if consist.kind == "software":
136            # Explicit software consist: don't touch the wire.
137            return
138        if not consist.locos:
139            return
140        lead_addr = consist.locos[0].address
141        desired: set[tuple[int, int]] = {
142            (lead_addr, m.address) for m in consist.locos[1:]
143        }
144        current = self._linked.setdefault(consist.address, set())
145        to_unlink = current - desired
146        to_link = desired - current
147        for lead, follower in to_unlink:
148            await self._unlink_pair(lead, follower)
149            current.discard((lead, follower))
150        for lead, follower in to_link:
151            await self._link_pair(lead, follower)
152            current.add((lead, follower))
153
154    async def _before_delete(self, consist: Consist) -> None:
155        """Unlink every follower before dropping the consist."""
156        if consist.kind == "software":
157            return
158        for lead_addr, follower_addr in list(
159            self._linked.get(consist.address, set())
160        ):
161            await self._unlink_pair(lead_addr, follower_addr)
162        self._linked.pop(consist.address, None)
163
164    async def set_speed(self, consist_address: int, speed: float) -> None:
165        """Send to the lead only — the CS fans via LINK.
166
167        For a ``kind="software"`` consist stored alongside CS-managed
168        ones, we still delegate to the base's per-loco fan-out.
169        """
170        consist = self._require(consist_address)
171        if consist.kind == "software":
172            await super().set_speed(consist_address, speed)
173            return
174        if not consist.locos:
175            return
176        lead = consist.locos[0]
177        throttle = await self._throttles.acquire(
178            lead.address, long_address=lead.long_address,
179        )
180        await throttle.set_speed(speed)
181
182    async def set_direction(
183        self, consist_address: int, direction: ThrottleDirection,
184    ) -> None:
185        """Send to the lead only — the CS fans via LINK.
186
187        Per-loco orientation for software consists is preserved by the
188        base class; for CS consists, the orientation was baked in at
189        LINK time on each follower's SLOT_STAT byte (bit 6) — outside
190        the scope of this initial pass. Callers wanting inverted
191        followers should for now use a ``software`` consist.
192        """
193        consist = self._require(consist_address)
194        if consist.kind == "software":
195            await super().set_direction(consist_address, direction)
196            return
197        if not consist.locos:
198            return
199        lead = consist.locos[0]
200        throttle = await self._throttles.acquire(
201            lead.address, long_address=lead.long_address,
202        )
203        await throttle.set_direction(direction)
204
205    async def emergency_stop(self, consist_address: int) -> None:
206        """Send e-stop to the lead — CS fans it out.
207
208        For extra safety we also fan to any acquired follower throttle
209        so a decoder that's momentarily out of sync with the CS still
210        stops. Matches JMRI Java's belt-and-braces approach on the
211        e-stop path.
212        """
213        consist = self._require(consist_address)
214        if consist.kind == "software":
215            await super().emergency_stop(consist_address)
216            return
217        for member in consist.locos:
218            throttle = await self._throttles.acquire(
219                member.address, long_address=member.long_address,
220            )
221            await throttle.emergency_stop()
222
223    # ``set_function`` intentionally *not* overridden: functions are per
224    # loco on LocoNet — LINK doesn't fan them — so the base's fan-out is
225    # exactly what we want.

LocoNet consist manager backed by OPC_LINK_SLOTS / OPC_UNLINK_SLOTS.

Consists are command_station by default here — the CS is doing the fan-out, not us. If a caller explicitly creates a consist with kind="software" we still store it and drive it via base fan-out, so the two modes coexist.

LnConsistManager( throttles: jmri_loconet.LnThrottleManager, system_prefix: str, *, tc: jmri_loconet.LocoNetTrafficController, slots: jmri_loconet.SlotManager)
63    def __init__(
64        self,
65        throttles: LnThrottleManager,
66        system_prefix: str,
67        *,
68        tc: LocoNetTrafficController,
69        slots: SlotManager,
70    ) -> None:
71        super().__init__(throttles, system_prefix)
72        self._tc = tc
73        self._slots = slots
74        # Per-consist set of currently-linked (lead_addr, follower_addr)
75        # pairs. Kept alongside the consist's own locos list so we can
76        # diff on membership change and only issue LINK/UNLINK for the
77        # deltas — repeatedly sending LINK for an already-linked pair
78        # confuses some CS firmware versions.
79        self._linked: dict[int, set[tuple[int, int]]] = {}
default_kind = 'command_station'
async def set_speed(self, consist_address: int, speed: float) -> None:
164    async def set_speed(self, consist_address: int, speed: float) -> None:
165        """Send to the lead only — the CS fans via LINK.
166
167        For a ``kind="software"`` consist stored alongside CS-managed
168        ones, we still delegate to the base's per-loco fan-out.
169        """
170        consist = self._require(consist_address)
171        if consist.kind == "software":
172            await super().set_speed(consist_address, speed)
173            return
174        if not consist.locos:
175            return
176        lead = consist.locos[0]
177        throttle = await self._throttles.acquire(
178            lead.address, long_address=lead.long_address,
179        )
180        await throttle.set_speed(speed)

Send to the lead only — the CS fans via LINK.

For a kind="software" consist stored alongside CS-managed ones, we still delegate to the base's per-loco fan-out.

async def set_direction( self, consist_address: int, direction: jmri_core.state.ThrottleDirection) -> None:
182    async def set_direction(
183        self, consist_address: int, direction: ThrottleDirection,
184    ) -> None:
185        """Send to the lead only — the CS fans via LINK.
186
187        Per-loco orientation for software consists is preserved by the
188        base class; for CS consists, the orientation was baked in at
189        LINK time on each follower's SLOT_STAT byte (bit 6) — outside
190        the scope of this initial pass. Callers wanting inverted
191        followers should for now use a ``software`` consist.
192        """
193        consist = self._require(consist_address)
194        if consist.kind == "software":
195            await super().set_direction(consist_address, direction)
196            return
197        if not consist.locos:
198            return
199        lead = consist.locos[0]
200        throttle = await self._throttles.acquire(
201            lead.address, long_address=lead.long_address,
202        )
203        await throttle.set_direction(direction)

Send to the lead only — the CS fans via LINK.

Per-loco orientation for software consists is preserved by the base class; for CS consists, the orientation was baked in at LINK time on each follower's SLOT_STAT byte (bit 6) — outside the scope of this initial pass. Callers wanting inverted followers should for now use a software consist.

async def emergency_stop(self, consist_address: int) -> None:
205    async def emergency_stop(self, consist_address: int) -> None:
206        """Send e-stop to the lead — CS fans it out.
207
208        For extra safety we also fan to any acquired follower throttle
209        so a decoder that's momentarily out of sync with the CS still
210        stops. Matches JMRI Java's belt-and-braces approach on the
211        e-stop path.
212        """
213        consist = self._require(consist_address)
214        if consist.kind == "software":
215            await super().emergency_stop(consist_address)
216            return
217        for member in consist.locos:
218            throttle = await self._throttles.acquire(
219                member.address, long_address=member.long_address,
220            )
221            await throttle.emergency_stop()

Send e-stop to the lead — CS fans it out.

For extra safety we also fan to any acquired follower throttle so a decoder that's momentarily out of sync with the CS still stops. Matches JMRI Java's belt-and-braces approach on the e-stop path.