jmri_loconet.slots
SlotManager — Digitrax LocoNet slot lifecycle.
LocoNet doesn't address locomotives directly; it works through up to ~120 "slots" maintained by the command station. To run a loco at DCC address N, a client:
- Sends
OPC_LOCO_ADRfor N. - Receives
OPC_SL_RD_DATAreporting which slot holds N (or a free slot if N wasn't held). - Sends
OPC_MOVE_SLOTS src=src dest=srcto claim it (this writes theLOCO_IN_USEstatus). - Issues
OPC_LOCO_SPD/DIRF/SNDto that slot. - Refreshes the slot periodically (
OPC_MOVE_SLOTS src=src dest=src) to prevent the command station from purging it. - On release, sends a status update to
LOCO_COMMON.
This is the smaller-scope port: enough to acquire a slot, drive a throttle,
and refresh it. Programming-track operations (slot 124) live in a later
module; consist management (OPC_LINK_SLOTS / OPC_UNLINK_SLOTS) is
deferred.
1"""``SlotManager`` — Digitrax LocoNet slot lifecycle. 2 3LocoNet doesn't address locomotives directly; it works through up to ~120 4"slots" maintained by the command station. To run a loco at DCC address N, 5a client: 6 71. Sends ``OPC_LOCO_ADR`` for N. 82. Receives ``OPC_SL_RD_DATA`` reporting which slot holds N (or a free slot 9 if N wasn't held). 103. Sends ``OPC_MOVE_SLOTS src=src dest=src`` to claim it (this writes the 11 ``LOCO_IN_USE`` status). 124. Issues ``OPC_LOCO_SPD/DIRF/SND`` to that slot. 135. Refreshes the slot periodically (``OPC_MOVE_SLOTS src=src dest=src``) to 14 prevent the command station from purging it. 156. On release, sends a status update to ``LOCO_COMMON``. 16 17This is the smaller-scope port: enough to acquire a slot, drive a throttle, 18and refresh it. Programming-track operations (slot 124) live in a later 19module; consist management (``OPC_LINK_SLOTS`` / ``OPC_UNLINK_SLOTS``) is 20deferred. 21""" 22from __future__ import annotations 23 24import asyncio 25import logging 26from collections.abc import Iterator 27from dataclasses import dataclass 28 29from jmri_loconet.messages import ( 30 LocoNetMessage, 31 OpcLocoAdr, 32 OpcMoveSlots, 33 OpcSlotStat1, 34 OpcSlRdData, 35) 36from jmri_loconet.opcodes import LOCO_COMMON, LOCO_IN_USE, LOCO_STAT_MASK 37from jmri_loconet.traffic import LocoNetTrafficController 38 39_log = logging.getLogger(__name__) 40 41# Slot 0 is reserved; slot 124 is the programming-track slot. 42PROGRAMMING_SLOT = 124 43MAX_USER_SLOT = 119 44 45 46@dataclass(slots=True) 47class Slot: 48 """Mutable mirror of one command-station slot.""" 49 50 number: int 51 address: int = 0 52 stat: int = 0 53 speed: int = 0 54 dirf: int = 0 55 snd: int = 0 56 trk: int = 0 57 58 @property 59 def in_use(self) -> bool: 60 return (self.stat & LOCO_STAT_MASK) == LOCO_IN_USE 61 62 63class SlotManager: 64 """Owns the per-slot state mirror and the acquire/release workflow.""" 65 66 def __init__(self, tc: LocoNetTrafficController) -> None: 67 self._tc = tc 68 self._slots: dict[int, Slot] = {} 69 self._by_address: dict[int, Slot] = {} 70 self._waiters: dict[int, asyncio.Future[Slot]] = {} 71 self._inbound_unsub = tc.inbound.connect(self._on_message) 72 73 # ----- acquire / release ----------------------------------------------- 74 75 async def acquire(self, dcc_address: int, *, timeout: float = 2.0) -> Slot: 76 """Acquire a slot for *dcc_address*. Returns the populated :class:`Slot`. 77 78 If we already hold a slot for the address, return it directly. Otherwise 79 send OPC_LOCO_ADR and wait for the command station's OPC_SL_RD_DATA 80 response — which arrives at us via the inbound listener. 81 """ 82 if dcc_address in self._by_address: 83 return self._by_address[dcc_address] 84 85 fut: asyncio.Future[Slot] = asyncio.get_event_loop().create_future() 86 self._waiters[dcc_address] = fut 87 await self._tc.send(OpcLocoAdr(address=dcc_address)) 88 try: 89 return await asyncio.wait_for(fut, timeout) 90 finally: 91 self._waiters.pop(dcc_address, None) 92 93 async def claim(self, slot: Slot) -> None: 94 """Send the null-move that flips the slot to LOCO_IN_USE.""" 95 await self._tc.send(OpcMoveSlots(src=slot.number, dest=slot.number)) 96 97 async def release(self, slot: Slot) -> None: 98 """Release the slot cleanly — mark it ``LOCO_COMMON`` so the 99 command station can purge it after its idle timeout without 100 losing the loco's speed/direction state. 101 102 The old implementation (``OPC_MOVE_SLOTS src=<n> dest=0``) 103 transferred slot ownership to slot 0, which real command 104 stations reinterpret as "dispatch this slot" — the loco 105 stops responding to us, keeps the same speed/direction, and 106 the CS starts purging it. Worse, the JMRI Java throttle uses 107 the *same* transient move-slot pattern to acquire a 108 dispatched slot, so an inadvertent release-then-reacquire 109 could steal the slot back before the CS actually purged it. 110 Matching JMRI Java's :func:`LocoNetSlot.releaseSlot` here. 111 112 Wire: ``OPC_SLOT_STAT1(slot=<n>, stat=(current & ~0x30) | 113 LOCO_COMMON)`` — clears the two loco-status bits and rewrites 114 them to LOCO_COMMON. Non-status bits (consist flags, 115 DCC-address type, etc.) are preserved. 116 """ 117 released_stat = (slot.stat & ~LOCO_STAT_MASK) | LOCO_COMMON 118 await self._tc.send( 119 OpcSlotStat1(slot=slot.number, stat=released_stat), 120 ) 121 self._by_address.pop(slot.address, None) 122 123 # ----- mirror access --------------------------------------------------- 124 125 def get(self, number: int) -> Slot | None: 126 return self._slots.get(number) 127 128 def by_address(self, address: int) -> Slot | None: 129 return self._by_address.get(address) 130 131 def __iter__(self) -> Iterator[Slot]: 132 return iter(self._slots.values()) 133 134 # ----- inbound listener ------------------------------------------------ 135 136 async def _on_message(self, msg: LocoNetMessage) -> None: 137 if not isinstance(msg, OpcSlRdData): 138 return 139 slot = self._slots.get(msg.slot) 140 if slot is None: 141 slot = Slot(number=msg.slot) 142 self._slots[msg.slot] = slot 143 # Update the mirror. 144 slot.stat = msg.stat 145 slot.speed = msg.speed 146 slot.dirf = msg.dirf 147 slot.snd = msg.snd 148 slot.trk = msg.trk 149 slot.address = msg.address 150 151 # Maintain the by-address index. 152 if msg.address: 153 self._by_address[msg.address] = slot 154 155 # Wake any acquire() waiting for this address. 156 if msg.address in self._waiters and not self._waiters[msg.address].done(): 157 self._waiters[msg.address].set_result(slot)
47@dataclass(slots=True) 48class Slot: 49 """Mutable mirror of one command-station slot.""" 50 51 number: int 52 address: int = 0 53 stat: int = 0 54 speed: int = 0 55 dirf: int = 0 56 snd: int = 0 57 trk: int = 0 58 59 @property 60 def in_use(self) -> bool: 61 return (self.stat & LOCO_STAT_MASK) == LOCO_IN_USE
Mutable mirror of one command-station slot.
64class SlotManager: 65 """Owns the per-slot state mirror and the acquire/release workflow.""" 66 67 def __init__(self, tc: LocoNetTrafficController) -> None: 68 self._tc = tc 69 self._slots: dict[int, Slot] = {} 70 self._by_address: dict[int, Slot] = {} 71 self._waiters: dict[int, asyncio.Future[Slot]] = {} 72 self._inbound_unsub = tc.inbound.connect(self._on_message) 73 74 # ----- acquire / release ----------------------------------------------- 75 76 async def acquire(self, dcc_address: int, *, timeout: float = 2.0) -> Slot: 77 """Acquire a slot for *dcc_address*. Returns the populated :class:`Slot`. 78 79 If we already hold a slot for the address, return it directly. Otherwise 80 send OPC_LOCO_ADR and wait for the command station's OPC_SL_RD_DATA 81 response — which arrives at us via the inbound listener. 82 """ 83 if dcc_address in self._by_address: 84 return self._by_address[dcc_address] 85 86 fut: asyncio.Future[Slot] = asyncio.get_event_loop().create_future() 87 self._waiters[dcc_address] = fut 88 await self._tc.send(OpcLocoAdr(address=dcc_address)) 89 try: 90 return await asyncio.wait_for(fut, timeout) 91 finally: 92 self._waiters.pop(dcc_address, None) 93 94 async def claim(self, slot: Slot) -> None: 95 """Send the null-move that flips the slot to LOCO_IN_USE.""" 96 await self._tc.send(OpcMoveSlots(src=slot.number, dest=slot.number)) 97 98 async def release(self, slot: Slot) -> None: 99 """Release the slot cleanly — mark it ``LOCO_COMMON`` so the 100 command station can purge it after its idle timeout without 101 losing the loco's speed/direction state. 102 103 The old implementation (``OPC_MOVE_SLOTS src=<n> dest=0``) 104 transferred slot ownership to slot 0, which real command 105 stations reinterpret as "dispatch this slot" — the loco 106 stops responding to us, keeps the same speed/direction, and 107 the CS starts purging it. Worse, the JMRI Java throttle uses 108 the *same* transient move-slot pattern to acquire a 109 dispatched slot, so an inadvertent release-then-reacquire 110 could steal the slot back before the CS actually purged it. 111 Matching JMRI Java's :func:`LocoNetSlot.releaseSlot` here. 112 113 Wire: ``OPC_SLOT_STAT1(slot=<n>, stat=(current & ~0x30) | 114 LOCO_COMMON)`` — clears the two loco-status bits and rewrites 115 them to LOCO_COMMON. Non-status bits (consist flags, 116 DCC-address type, etc.) are preserved. 117 """ 118 released_stat = (slot.stat & ~LOCO_STAT_MASK) | LOCO_COMMON 119 await self._tc.send( 120 OpcSlotStat1(slot=slot.number, stat=released_stat), 121 ) 122 self._by_address.pop(slot.address, None) 123 124 # ----- mirror access --------------------------------------------------- 125 126 def get(self, number: int) -> Slot | None: 127 return self._slots.get(number) 128 129 def by_address(self, address: int) -> Slot | None: 130 return self._by_address.get(address) 131 132 def __iter__(self) -> Iterator[Slot]: 133 return iter(self._slots.values()) 134 135 # ----- inbound listener ------------------------------------------------ 136 137 async def _on_message(self, msg: LocoNetMessage) -> None: 138 if not isinstance(msg, OpcSlRdData): 139 return 140 slot = self._slots.get(msg.slot) 141 if slot is None: 142 slot = Slot(number=msg.slot) 143 self._slots[msg.slot] = slot 144 # Update the mirror. 145 slot.stat = msg.stat 146 slot.speed = msg.speed 147 slot.dirf = msg.dirf 148 slot.snd = msg.snd 149 slot.trk = msg.trk 150 slot.address = msg.address 151 152 # Maintain the by-address index. 153 if msg.address: 154 self._by_address[msg.address] = slot 155 156 # Wake any acquire() waiting for this address. 157 if msg.address in self._waiters and not self._waiters[msg.address].done(): 158 self._waiters[msg.address].set_result(slot)
Owns the per-slot state mirror and the acquire/release workflow.
76 async def acquire(self, dcc_address: int, *, timeout: float = 2.0) -> Slot: 77 """Acquire a slot for *dcc_address*. Returns the populated :class:`Slot`. 78 79 If we already hold a slot for the address, return it directly. Otherwise 80 send OPC_LOCO_ADR and wait for the command station's OPC_SL_RD_DATA 81 response — which arrives at us via the inbound listener. 82 """ 83 if dcc_address in self._by_address: 84 return self._by_address[dcc_address] 85 86 fut: asyncio.Future[Slot] = asyncio.get_event_loop().create_future() 87 self._waiters[dcc_address] = fut 88 await self._tc.send(OpcLocoAdr(address=dcc_address)) 89 try: 90 return await asyncio.wait_for(fut, timeout) 91 finally: 92 self._waiters.pop(dcc_address, None)
Acquire a slot for dcc_address. Returns the populated Slot.
If we already hold a slot for the address, return it directly. Otherwise send OPC_LOCO_ADR and wait for the command station's OPC_SL_RD_DATA response — which arrives at us via the inbound listener.
94 async def claim(self, slot: Slot) -> None: 95 """Send the null-move that flips the slot to LOCO_IN_USE.""" 96 await self._tc.send(OpcMoveSlots(src=slot.number, dest=slot.number))
Send the null-move that flips the slot to LOCO_IN_USE.
98 async def release(self, slot: Slot) -> None: 99 """Release the slot cleanly — mark it ``LOCO_COMMON`` so the 100 command station can purge it after its idle timeout without 101 losing the loco's speed/direction state. 102 103 The old implementation (``OPC_MOVE_SLOTS src=<n> dest=0``) 104 transferred slot ownership to slot 0, which real command 105 stations reinterpret as "dispatch this slot" — the loco 106 stops responding to us, keeps the same speed/direction, and 107 the CS starts purging it. Worse, the JMRI Java throttle uses 108 the *same* transient move-slot pattern to acquire a 109 dispatched slot, so an inadvertent release-then-reacquire 110 could steal the slot back before the CS actually purged it. 111 Matching JMRI Java's :func:`LocoNetSlot.releaseSlot` here. 112 113 Wire: ``OPC_SLOT_STAT1(slot=<n>, stat=(current & ~0x30) | 114 LOCO_COMMON)`` — clears the two loco-status bits and rewrites 115 them to LOCO_COMMON. Non-status bits (consist flags, 116 DCC-address type, etc.) are preserved. 117 """ 118 released_stat = (slot.stat & ~LOCO_STAT_MASK) | LOCO_COMMON 119 await self._tc.send( 120 OpcSlotStat1(slot=slot.number, stat=released_stat), 121 ) 122 self._by_address.pop(slot.address, None)
Release the slot cleanly — mark it LOCO_COMMON so the
command station can purge it after its idle timeout without
losing the loco's speed/direction state.
The old implementation (OPC_MOVE_SLOTS src=<n> dest=0)
transferred slot ownership to slot 0, which real command
stations reinterpret as "dispatch this slot" — the loco
stops responding to us, keeps the same speed/direction, and
the CS starts purging it. Worse, the JMRI Java throttle uses
the same transient move-slot pattern to acquire a
dispatched slot, so an inadvertent release-then-reacquire
could steal the slot back before the CS actually purged it.
Matching JMRI Java's LocoNetSlot.releaseSlot() here.
Wire: OPC_SLOT_STAT1(slot=<n>, stat=(current & ~0x30) |
LOCO_COMMON) — clears the two loco-status bits and rewrites
them to LOCO_COMMON. Non-status bits (consist flags,
DCC-address type, etc.) are preserved.