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)— attachsrcas a follower ofdest. Repeat for each additional follower.OPC_UNLINK_SLOTS(src, dest)— detachsrcfromdest.
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, sendingLINK/UNLINKfor 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.
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.
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]]] = {}
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.
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.
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.