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:

  1. Sends OPC_LOCO_ADR for N.
  2. Receives OPC_SL_RD_DATA reporting which slot holds N (or a free slot if N wasn't held).
  3. Sends OPC_MOVE_SLOTS src=src dest=src to claim it (this writes the LOCO_IN_USE status).
  4. Issues OPC_LOCO_SPD/DIRF/SND to that slot.
  5. Refreshes the slot periodically (OPC_MOVE_SLOTS src=src dest=src) to prevent the command station from purging it.
  6. 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)
PROGRAMMING_SLOT = 124
MAX_USER_SLOT = 119
@dataclass(slots=True)
class 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.

Slot( number: int, address: int = 0, stat: int = 0, speed: int = 0, dirf: int = 0, snd: int = 0, trk: int = 0)
number: int
address: int
stat: int
speed: int
dirf: int
snd: int
trk: int
in_use: bool
59    @property
60    def in_use(self) -> bool:
61        return (self.stat & LOCO_STAT_MASK) == LOCO_IN_USE
class SlotManager:
 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.

SlotManager(tc: jmri_loconet.LocoNetTrafficController)
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)
async def acquire( self, dcc_address: int, *, timeout: float = 2.0) -> Slot:
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.

async def claim(self, slot: Slot) -> None:
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.

async def release(self, slot: Slot) -> None:
 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.

def get(self, number: int) -> Slot | None:
126    def get(self, number: int) -> Slot | None:
127        return self._slots.get(number)
def by_address(self, address: int) -> Slot | None:
129    def by_address(self, address: int) -> Slot | None:
130        return self._by_address.get(address)