jmri_loconet

jmri-loconet — Stage 4 of the JMRI Python rewrite.

The first real hardware driver. Subclass of jmri_core.hal.SystemConnection that drives the LocoNet wire protocol (Digitrax). Registered via [project.entry-points."jmri.connection"] loconet so the core plugin discovery picks it up after a pip install.

 1"""jmri-loconet — Stage 4 of the JMRI Python rewrite.
 2
 3The first real hardware driver. Subclass of ``jmri_core.hal.SystemConnection``
 4that drives the LocoNet wire protocol (Digitrax). Registered via
 5``[project.entry-points."jmri.connection"] loconet`` so the core plugin
 6discovery picks it up after a ``pip install``.
 7"""
 8from jmri_loconet.beans import (
 9    LnPowerManager,
10    LnSensor,
11    LnSensorManager,
12    LnTurnout,
13    LnTurnoutManager,
14)
15from jmri_loconet.connection import LocoNetConnection
16from jmri_loconet.programmer import (
17    LnOpsProgrammer,
18    LnProgrammer,
19    LnProgrammerManager,
20    LnServiceProgrammer,
21)
22from jmri_loconet.slots import Slot, SlotManager
23from jmri_loconet.throttle import LnThrottle, LnThrottleManager
24from jmri_loconet.traffic import LocoNetTrafficController
25from jmri_loconet.transport import LocoNetPort, LoopbackPort, SerialPort, TcpPort, loopback_pair
26
27__version__ = "0.1.0"
28
29__all__ = [
30    "LnOpsProgrammer",
31    "LnPowerManager",
32    "LnProgrammer",
33    "LnProgrammerManager",
34    "LnSensor",
35    "LnSensorManager",
36    "LnServiceProgrammer",
37    "LnThrottle",
38    "LnThrottleManager",
39    "LnTurnout",
40    "LnTurnoutManager",
41    "LocoNetConnection",
42    "LocoNetPort",
43    "LocoNetTrafficController",
44    "LoopbackPort",
45    "SerialPort",
46    "Slot",
47    "SlotManager",
48    "TcpPort",
49    "__version__",
50    "loopback_pair",
51]
class LnOpsProgrammer(jmri_core.beans.programmer.Programmer):
256class LnOpsProgrammer(Programmer):
257    """Ops-mode adapter scoped to a specific DCC address."""
258
259    def __init__(self, inner: LnProgrammer, address: int, *, long_address: bool) -> None:
260        self._inner = inner
261        self._address = address
262        self._long = long_address
263
264    @property
265    def mode(self) -> ProgrammingMode:
266        return ProgrammingMode.OPS_MODE
267
268    @property
269    def address(self) -> int:
270        return self._address
271
272    async def read_cv(self, cv: int) -> int:
273        return await self._inner.read_cv_ops(self._address, cv)
274
275    async def write_cv(self, cv: int, value: int) -> None:
276        await self._inner.write_cv_ops(self._address, cv, value)

Ops-mode adapter scoped to a specific DCC address.

LnOpsProgrammer( inner: LnProgrammer, address: int, *, long_address: bool)
259    def __init__(self, inner: LnProgrammer, address: int, *, long_address: bool) -> None:
260        self._inner = inner
261        self._address = address
262        self._long = long_address
264    @property
265    def mode(self) -> ProgrammingMode:
266        return ProgrammingMode.OPS_MODE

Which mode this programmer instance uses.

address: int
268    @property
269    def address(self) -> int:
270        return self._address
async def read_cv(self, cv: int) -> int:
272    async def read_cv(self, cv: int) -> int:
273        return await self._inner.read_cv_ops(self._address, cv)

Read CV cv, returns 0..255.

async def write_cv(self, cv: int, value: int) -> None:
275    async def write_cv(self, cv: int, value: int) -> None:
276        await self._inner.write_cv_ops(self._address, cv, value)

Write value to CV cv.

class LnPowerManager(jmri_core.beans.power.PowerManager):
130class LnPowerManager(PowerManager):
131    """LocoNet track power. Sends OPC_GPON/OPC_GPOFF/OPC_IDLE."""
132
133    def __init__(self, tc: LocoNetTrafficController) -> None:
134        super().__init__()
135        self._tc = tc
136        self._unsub = tc.inbound.connect(self._on_message)
137
138    async def set_power(self, state: PowerState) -> None:
139        if state == PowerState.ON:
140            await self._tc.send(OpcGpOn())
141        elif state == PowerState.OFF:
142            await self._tc.send(OpcGpOff())
143        elif state == PowerState.IDLE:
144            await self._tc.send(OpcIdle())
145        await self._set_state(state)
146
147    async def _on_message(self, msg: LocoNetMessage) -> None:
148        if isinstance(msg, OpcGpOn):
149            await self._set_state(PowerState.ON)
150        elif isinstance(msg, OpcGpOff):
151            await self._set_state(PowerState.OFF)
152        elif isinstance(msg, OpcIdle):
153            await self._set_state(PowerState.IDLE)

LocoNet track power. Sends OPC_GPON/OPC_GPOFF/OPC_IDLE.

LnPowerManager(tc: LocoNetTrafficController)
133    def __init__(self, tc: LocoNetTrafficController) -> None:
134        super().__init__()
135        self._tc = tc
136        self._unsub = tc.inbound.connect(self._on_message)
async def set_power(self, state: jmri_core.state.PowerState) -> None:
138    async def set_power(self, state: PowerState) -> None:
139        if state == PowerState.ON:
140            await self._tc.send(OpcGpOn())
141        elif state == PowerState.OFF:
142            await self._tc.send(OpcGpOff())
143        elif state == PowerState.IDLE:
144            await self._tc.send(OpcIdle())
145        await self._set_state(state)

Command the layout to state. Base impl just updates in-memory.

class LnProgrammer:
139class LnProgrammer:
140    """Service- and ops-mode CV access against a LocoNet command station."""
141
142    def __init__(self, tc: LocoNetTrafficController) -> None:
143        self._tc = tc
144        # One waiter at a time (LocoNet programming is sequential).
145        self._busy = asyncio.Lock()
146
147    async def write_cv_direct(self, cv: int, value: int, *, timeout: float = 6.0) -> None:
148        """Service-mode-direct CV write. Blocks until OPC_LONG_ACK arrives."""
149        async with self._busy:
150            await self._send_and_wait_ack(
151                _build_prog_slot_message(PCMD_SERVICE_DIRECT_WRITE, cv, value),
152                timeout=timeout,
153            )
154
155    async def read_cv_direct(self, cv: int, *, timeout: float = 6.0) -> int:
156        """Service-mode-direct CV read. Returns the value byte."""
157        async with self._busy:
158            return await self._send_and_wait_read(
159                _build_prog_slot_message(PCMD_SERVICE_DIRECT_READ, cv, 0),
160                timeout=timeout,
161            )
162
163    async def write_cv_ops(
164        self, address: int, cv: int, value: int, *, timeout: float = 2.0,
165    ) -> None:
166        """Ops-mode CV write to *address*. No reply needed; we fire-and-forget."""
167        async with self._busy:
168            await self._tc.send(_build_prog_slot_message(
169                PCMD_OPS_MODE_WRITE, cv, value, ops_address=address,
170            ))
171
172    async def read_cv_ops(
173        self, address: int, cv: int, *, timeout: float = 6.0,
174    ) -> int:
175        """Ops-mode CV read — requires RailCom-capable decoder + readers."""
176        async with self._busy:
177            return await self._send_and_wait_read(
178                _build_prog_slot_message(
179                    PCMD_OPS_MODE_READ, cv, 0, ops_address=address,
180                ),
181                timeout=timeout,
182            )
183
184    # ----- internals -------------------------------------------------------
185
186    async def _send_and_wait_ack(self, msg: OpcWrSlData, *, timeout: float) -> None:
187        future: asyncio.Future[None] = asyncio.get_event_loop().create_future()
188
189        async def listener(inbound: LocoNetMessage) -> None:
190            if future.done():
191                return
192            if isinstance(inbound, OpcLongAck) and inbound.responding_opcode == OPC_WR_SL_DATA:
193                if inbound.ack_code == LONG_ACK_WRITE_DONE:
194                    future.set_result(None)
195                else:
196                    future.set_exception(
197                        RuntimeError(f"LocoNet programming write failed: ack={inbound.ack_code:#x}")
198                    )
199
200        disconnect = self._tc.inbound.connect(listener)
201        try:
202            await self._tc.send(msg)
203            await asyncio.wait_for(future, timeout=timeout)
204        finally:
205            disconnect()
206
207    async def _send_and_wait_read(self, msg: OpcWrSlData, *, timeout: float) -> int:
208        future: asyncio.Future[int] = asyncio.get_event_loop().create_future()
209
210        async def listener(inbound: LocoNetMessage) -> None:
211            if future.done():
212                return
213            if isinstance(inbound, OpcSlRdData) and inbound.slot == PROGRAMMING_SLOT:
214                # The slot-rd frame carries (CVH, DATA7) at the same byte
215                # positions our OpcSlRdData decoded as (address_high, snd).
216                cvh = (inbound.address >> 7) & 0x7F
217                value = _unpack_data(cvh, inbound.snd)
218                future.set_result(value)
219            elif (
220                isinstance(inbound, OpcLongAck)
221                and inbound.responding_opcode == OPC_WR_SL_DATA
222                and inbound.ack_code == LONG_ACK_READ_FAIL
223            ):
224                future.set_exception(
225                    RuntimeError("LocoNet programming read failed (decoder did not respond)")
226                )
227
228        disconnect = self._tc.inbound.connect(listener)
229        try:
230            await self._tc.send(msg)
231            return await asyncio.wait_for(future, timeout=timeout)
232        finally:
233            disconnect()

Service- and ops-mode CV access against a LocoNet command station.

LnProgrammer(tc: LocoNetTrafficController)
142    def __init__(self, tc: LocoNetTrafficController) -> None:
143        self._tc = tc
144        # One waiter at a time (LocoNet programming is sequential).
145        self._busy = asyncio.Lock()
async def write_cv_direct(self, cv: int, value: int, *, timeout: float = 6.0) -> None:
147    async def write_cv_direct(self, cv: int, value: int, *, timeout: float = 6.0) -> None:
148        """Service-mode-direct CV write. Blocks until OPC_LONG_ACK arrives."""
149        async with self._busy:
150            await self._send_and_wait_ack(
151                _build_prog_slot_message(PCMD_SERVICE_DIRECT_WRITE, cv, value),
152                timeout=timeout,
153            )

Service-mode-direct CV write. Blocks until OPC_LONG_ACK arrives.

async def read_cv_direct(self, cv: int, *, timeout: float = 6.0) -> int:
155    async def read_cv_direct(self, cv: int, *, timeout: float = 6.0) -> int:
156        """Service-mode-direct CV read. Returns the value byte."""
157        async with self._busy:
158            return await self._send_and_wait_read(
159                _build_prog_slot_message(PCMD_SERVICE_DIRECT_READ, cv, 0),
160                timeout=timeout,
161            )

Service-mode-direct CV read. Returns the value byte.

async def write_cv_ops(self, address: int, cv: int, value: int, *, timeout: float = 2.0) -> None:
163    async def write_cv_ops(
164        self, address: int, cv: int, value: int, *, timeout: float = 2.0,
165    ) -> None:
166        """Ops-mode CV write to *address*. No reply needed; we fire-and-forget."""
167        async with self._busy:
168            await self._tc.send(_build_prog_slot_message(
169                PCMD_OPS_MODE_WRITE, cv, value, ops_address=address,
170            ))

Ops-mode CV write to address. No reply needed; we fire-and-forget.

async def read_cv_ops(self, address: int, cv: int, *, timeout: float = 6.0) -> int:
172    async def read_cv_ops(
173        self, address: int, cv: int, *, timeout: float = 6.0,
174    ) -> int:
175        """Ops-mode CV read — requires RailCom-capable decoder + readers."""
176        async with self._busy:
177            return await self._send_and_wait_read(
178                _build_prog_slot_message(
179                    PCMD_OPS_MODE_READ, cv, 0, ops_address=address,
180                ),
181                timeout=timeout,
182            )

Ops-mode CV read — requires RailCom-capable decoder + readers.

class LnProgrammerManager(jmri_core.beans.programmer.ProgrammerManager):
279class LnProgrammerManager(ProgrammerManager):
280    """Hands out LocoNet service/ops programmers backed by one :class:`LnProgrammer`."""
281
282    def __init__(self, inner: LnProgrammer) -> None:
283        self._inner = inner
284        self._service_singleton: LnServiceProgrammer | None = None
285
286    def global_programmer(self) -> LnServiceProgrammer:
287        if self._service_singleton is None:
288            self._service_singleton = LnServiceProgrammer(self._inner)
289        return self._service_singleton
290
291    def ops_programmer(self, address: int, *, long_address: bool = False) -> LnOpsProgrammer:
292        return LnOpsProgrammer(self._inner, address, long_address=long_address)
293
294    @property
295    def inner(self) -> LnProgrammer:
296        return self._inner

Hands out LocoNet service/ops programmers backed by one LnProgrammer.

LnProgrammerManager(inner: LnProgrammer)
282    def __init__(self, inner: LnProgrammer) -> None:
283        self._inner = inner
284        self._service_singleton: LnServiceProgrammer | None = None
def global_programmer(self) -> LnServiceProgrammer:
286    def global_programmer(self) -> LnServiceProgrammer:
287        if self._service_singleton is None:
288            self._service_singleton = LnServiceProgrammer(self._inner)
289        return self._service_singleton
def ops_programmer( self, address: int, *, long_address: bool = False) -> LnOpsProgrammer:
291    def ops_programmer(self, address: int, *, long_address: bool = False) -> LnOpsProgrammer:
292        return LnOpsProgrammer(self._inner, address, long_address=long_address)
inner: LnProgrammer
294    @property
295    def inner(self) -> LnProgrammer:
296        return self._inner
class LnSensor(jmri_core.beans.sensor.Sensor):
88class LnSensor(Sensor):
89    """LocoNet-aware sensor — read-only from the layout."""
90
91    __slots__ = ("_address",)
92
93    def __init__(self, system_name: str, address: int) -> None:
94        super().__init__(system_name)
95        self._address = address
96
97    @property
98    def address(self) -> int:
99        return self._address

LocoNet-aware sensor — read-only from the layout.

LnSensor(system_name: str, address: int)
93    def __init__(self, system_name: str, address: int) -> None:
94        super().__init__(system_name)
95        self._address = address
address: int
97    @property
98    def address(self) -> int:
99        return self._address
class LnSensorManager(jmri_core.manager.Manager[jmri_loconet.beans.LnSensor]):
102class LnSensorManager(Manager[LnSensor]):
103    """Manager that constructs LocoNet sensors and listens for OPC_INPUT_REP."""
104
105    type_letter = "S"
106
107    def __init__(self, system_prefix: str, *, tc: LocoNetTrafficController) -> None:
108        super().__init__(system_prefix)
109        self._tc = tc
110        self._by_address: dict[int, LnSensor] = {}
111        self._unsub = tc.inbound.connect(self._on_message)
112
113    def _create(self, system_name: str) -> LnSensor:
114        address = _parse_address(system_name, self.full_prefix)
115        bean = LnSensor(system_name, address)
116        self._by_address[address] = bean
117        return bean
118
119    async def _on_message(self, msg: LocoNetMessage) -> None:
120        if not isinstance(msg, OpcInputRep):
121            return
122        bean = self._by_address.get(msg.address)
123        if bean is None:
124            # Sensors aren't always pre-created; clients can opt in via provide().
125            return
126        state = SensorState.ACTIVE if msg.active else SensorState.INACTIVE
127        await bean._set_known_state(state)

Manager that constructs LocoNet sensors and listens for OPC_INPUT_REP.

LnSensorManager( system_prefix: str, *, tc: LocoNetTrafficController)
107    def __init__(self, system_prefix: str, *, tc: LocoNetTrafficController) -> None:
108        super().__init__(system_prefix)
109        self._tc = tc
110        self._by_address: dict[int, LnSensor] = {}
111        self._unsub = tc.inbound.connect(self._on_message)
type_letter = 'S'
class LnServiceProgrammer(jmri_core.beans.programmer.Programmer):
239class LnServiceProgrammer(Programmer):
240    """Service-mode-direct adapter satisfying the core ABC."""
241
242    def __init__(self, inner: LnProgrammer) -> None:
243        self._inner = inner
244
245    @property
246    def mode(self) -> ProgrammingMode:
247        return ProgrammingMode.SERVICE_DIRECT
248
249    async def read_cv(self, cv: int) -> int:
250        return await self._inner.read_cv_direct(cv)
251
252    async def write_cv(self, cv: int, value: int) -> None:
253        await self._inner.write_cv_direct(cv, value)

Service-mode-direct adapter satisfying the core ABC.

LnServiceProgrammer(inner: LnProgrammer)
242    def __init__(self, inner: LnProgrammer) -> None:
243        self._inner = inner
245    @property
246    def mode(self) -> ProgrammingMode:
247        return ProgrammingMode.SERVICE_DIRECT

Which mode this programmer instance uses.

async def read_cv(self, cv: int) -> int:
249    async def read_cv(self, cv: int) -> int:
250        return await self._inner.read_cv_direct(cv)

Read CV cv, returns 0..255.

async def write_cv(self, cv: int, value: int) -> None:
252    async def write_cv(self, cv: int, value: int) -> None:
253        await self._inner.write_cv_direct(cv, value)

Write value to CV cv.

class LnThrottle(jmri_core.beans.throttle.Throttle):
 88class LnThrottle(Throttle):
 89    """A LocoNet throttle bound to one acquired slot."""
 90
 91    def __init__(self, address: int, slot: Slot, tc: LocoNetTrafficController,
 92                 long_address: bool = False) -> None:
 93        super().__init__(address=address, long_address=long_address)
 94        self._slot = slot
 95        self._tc = tc
 96
 97    @property
 98    def slot(self) -> Slot:
 99        return self._slot
100
101    async def set_speed(self, speed: float) -> None:
102        await super().set_speed(speed)
103        await self._tc.send(OpcLocoSpd(slot=self._slot.number, speed=speed_to_wire(speed)))
104
105    async def set_direction(self, direction: ThrottleDirection) -> None:
106        await super().set_direction(direction)
107        await self._push_dirf()
108
109    async def set_function(self, index: int, on: bool) -> None:
110        await super().set_function(index, on)
111        if index <= 4:
112            await self._push_dirf()
113        elif index <= 8:
114            await self._tc.send(OpcLocoSnd(slot=self._slot.number, snd=pack_snd(self.functions)))
115        elif index <= 20:
116            await self._push_func_group(group=3)
117        elif index <= 28:
118            await self._push_func_group(group=4)
119
120    async def _push_func_group(self, *, group: int) -> None:
121        """Build an NMRA function-high DCC packet and ship it via OPC_IMM_PACKET."""
122        dcc = function_high_packet_dcc(
123            address=self.address,
124            long_address=self.long_address,
125            functions=self.functions,
126            group=group,
127        )
128        body = wrap_in_imm_packet(dcc, repeat=2)
129        # Send as a raw OpcUnknown with the IMM_PACKET opcode — the
130        # traffic controller will compute and append the checksum.
131        # The first byte of body is already the opcode; strip it so
132        # OpcUnknown's payload contains just the rest.
133        assert body[0] == OPC_IMM_PACKET
134        await self._tc.send(OpcUnknown(opcode=OPC_IMM_PACKET, payload=body[1:]))
135
136    async def _push_dirf(self) -> None:
137        await self._tc.send(OpcLocoDirF(
138            slot=self._slot.number,
139            dirf=pack_dirf(self.direction, self.functions),
140        ))

A LocoNet throttle bound to one acquired slot.

LnThrottle( address: int, slot: Slot, tc: LocoNetTrafficController, long_address: bool = False)
91    def __init__(self, address: int, slot: Slot, tc: LocoNetTrafficController,
92                 long_address: bool = False) -> None:
93        super().__init__(address=address, long_address=long_address)
94        self._slot = slot
95        self._tc = tc
slot: Slot
97    @property
98    def slot(self) -> Slot:
99        return self._slot
async def set_speed(self, speed: float) -> None:
101    async def set_speed(self, speed: float) -> None:
102        await super().set_speed(speed)
103        await self._tc.send(OpcLocoSpd(slot=self._slot.number, speed=speed_to_wire(speed)))
async def set_direction(self, direction: jmri_core.state.ThrottleDirection) -> None:
105    async def set_direction(self, direction: ThrottleDirection) -> None:
106        await super().set_direction(direction)
107        await self._push_dirf()
async def set_function(self, index: int, on: bool) -> None:
109    async def set_function(self, index: int, on: bool) -> None:
110        await super().set_function(index, on)
111        if index <= 4:
112            await self._push_dirf()
113        elif index <= 8:
114            await self._tc.send(OpcLocoSnd(slot=self._slot.number, snd=pack_snd(self.functions)))
115        elif index <= 20:
116            await self._push_func_group(group=3)
117        elif index <= 28:
118            await self._push_func_group(group=4)
class LnThrottleManager(jmri_core.beans.throttle_manager.ThrottleManager):
143class LnThrottleManager(ThrottleManager):
144    """LocoNet throttle manager — extends the core ThrottleManager.
145
146    Override hook :meth:`_construct` acquires a slot via :class:`SlotManager`
147    before handing the throttle back. :meth:`release` is overridden to
148    return the slot to the command station as well as drop the bookkeeping.
149
150    Owns the slot-refresh task: periodically sends a null-move
151    (``OPC_MOVE_SLOTS src=N dest=N``) on every active slot so the CS
152    doesn't purge it during long operations.
153    """
154
155    def __init__(
156        self,
157        system_prefix: str,
158        *,
159        tc: LocoNetTrafficController,
160        slot_manager: SlotManager,
161        refresh_interval: float = SLOT_REFRESH_INTERVAL_S,
162    ) -> None:
163        super().__init__(system_prefix)
164        self._tc = tc
165        self._slots = slot_manager
166        self._refresh_interval = refresh_interval
167        self._refresh_task: asyncio.Task[None] | None = None
168        self._refresh_stop = asyncio.Event()
169
170    async def _construct(self, address: int, *, long_address: bool) -> LnThrottle:
171        slot = await self._slots.acquire(address)
172        await self._slots.claim(slot)
173        return LnThrottle(
174            address=address, slot=slot, tc=self._tc, long_address=long_address
175        )
176
177    async def release(self, throttle: Throttle) -> None:
178        if isinstance(throttle, LnThrottle):
179            await self._slots.release(throttle.slot)
180        await super().release(throttle)
181
182    # ----- slot refresh task ------------------------------------------------
183
184    def start_refresh(self) -> None:
185        """Start the periodic slot-refresh background task. Idempotent."""
186        if self._refresh_task is not None:
187            return
188        self._refresh_stop.clear()
189        self._refresh_task = asyncio.create_task(
190            self._refresh_loop(), name="loconet-slot-refresh",
191        )
192
193    async def stop_refresh(self) -> None:
194        """Stop the slot-refresh task. Idempotent."""
195        if self._refresh_task is None:
196            return
197        self._refresh_stop.set()
198        self._refresh_task.cancel()
199        import contextlib  # noqa: PLC0415
200        with contextlib.suppress(asyncio.CancelledError, Exception):
201            await self._refresh_task
202        self._refresh_task = None
203
204    async def _refresh_loop(self) -> None:
205        import contextlib  # noqa: PLC0415
206        try:
207            while not self._refresh_stop.is_set():
208                with contextlib.suppress(TimeoutError):
209                    await asyncio.wait_for(
210                        self._refresh_stop.wait(),
211                        timeout=self._refresh_interval,
212                    )
213                if self._refresh_stop.is_set():
214                    return
215                await self._refresh_all_slots()
216        except asyncio.CancelledError:
217            return
218
219    async def _refresh_all_slots(self) -> None:
220        """Send a null-move for every currently-active throttle's slot."""
221        for throttle in list(self):
222            if not isinstance(throttle, LnThrottle):
223                continue
224            try:
225                await self._tc.send(OpcMoveSlots(
226                    src=throttle.slot.number, dest=throttle.slot.number,
227                ))
228            except Exception as e:  # noqa: BLE001
229                _log.warning("slot refresh failed for slot %d: %s", throttle.slot.number, e)

LocoNet throttle manager — extends the core ThrottleManager.

Override hook _construct() acquires a slot via SlotManager before handing the throttle back. release() is overridden to return the slot to the command station as well as drop the bookkeeping.

Owns the slot-refresh task: periodically sends a null-move (OPC_MOVE_SLOTS src=N dest=N) on every active slot so the CS doesn't purge it during long operations.

LnThrottleManager( system_prefix: str, *, tc: LocoNetTrafficController, slot_manager: SlotManager, refresh_interval: float = 90.0)
155    def __init__(
156        self,
157        system_prefix: str,
158        *,
159        tc: LocoNetTrafficController,
160        slot_manager: SlotManager,
161        refresh_interval: float = SLOT_REFRESH_INTERVAL_S,
162    ) -> None:
163        super().__init__(system_prefix)
164        self._tc = tc
165        self._slots = slot_manager
166        self._refresh_interval = refresh_interval
167        self._refresh_task: asyncio.Task[None] | None = None
168        self._refresh_stop = asyncio.Event()
async def release(self, throttle: jmri_core.beans.Throttle) -> None:
177    async def release(self, throttle: Throttle) -> None:
178        if isinstance(throttle, LnThrottle):
179            await self._slots.release(throttle.slot)
180        await super().release(throttle)
def start_refresh(self) -> None:
184    def start_refresh(self) -> None:
185        """Start the periodic slot-refresh background task. Idempotent."""
186        if self._refresh_task is not None:
187            return
188        self._refresh_stop.clear()
189        self._refresh_task = asyncio.create_task(
190            self._refresh_loop(), name="loconet-slot-refresh",
191        )

Start the periodic slot-refresh background task. Idempotent.

async def stop_refresh(self) -> None:
193    async def stop_refresh(self) -> None:
194        """Stop the slot-refresh task. Idempotent."""
195        if self._refresh_task is None:
196            return
197        self._refresh_stop.set()
198        self._refresh_task.cancel()
199        import contextlib  # noqa: PLC0415
200        with contextlib.suppress(asyncio.CancelledError, Exception):
201            await self._refresh_task
202        self._refresh_task = None

Stop the slot-refresh task. Idempotent.

class LnTurnout(jmri_core.beans.turnout.Turnout):
30class LnTurnout(Turnout):
31    """LocoNet-aware turnout: sends OPC_SW_REQ on commanded-state change."""
32
33    __slots__ = ("_address", "_tc")
34
35    def __init__(self, system_name: str, address: int, tc: LocoNetTrafficController) -> None:
36        super().__init__(system_name)
37        self._address = address
38        self._tc = tc
39
40    @property
41    def address(self) -> int:
42        return self._address
43
44    async def set_commanded_state(self, state: TurnoutState) -> None:
45        if state in (TurnoutState.CLOSED, TurnoutState.THROWN):
46            await self._tc.send(OpcSwReq(
47                address=self._address,
48                closed=(state == TurnoutState.CLOSED),
49                output_on=True,
50            ))
51        # Update the in-memory state via the base implementation; for
52        # DIRECT-feedback turnouts known_state will mirror this until/unless
53        # a layout report overrides it.
54        await super().set_commanded_state(state)

LocoNet-aware turnout: sends OPC_SW_REQ on commanded-state change.

LnTurnout( system_name: str, address: int, tc: LocoNetTrafficController)
35    def __init__(self, system_name: str, address: int, tc: LocoNetTrafficController) -> None:
36        super().__init__(system_name)
37        self._address = address
38        self._tc = tc
address: int
40    @property
41    def address(self) -> int:
42        return self._address
async def set_commanded_state(self, state: jmri_core.state.TurnoutState) -> None:
44    async def set_commanded_state(self, state: TurnoutState) -> None:
45        if state in (TurnoutState.CLOSED, TurnoutState.THROWN):
46            await self._tc.send(OpcSwReq(
47                address=self._address,
48                closed=(state == TurnoutState.CLOSED),
49                output_on=True,
50            ))
51        # Update the in-memory state via the base implementation; for
52        # DIRECT-feedback turnouts known_state will mirror this until/unless
53        # a layout report overrides it.
54        await super().set_commanded_state(state)

Ask the layout to throw or close the switch.

The base implementation only updates the in-memory commanded state and emits the signal. Driver subclasses override this to push a hardware message and then call super().set_commanded_state().

class LnTurnoutManager(jmri_core.manager.Manager[jmri_loconet.beans.LnTurnout]):
57class LnTurnoutManager(Manager[LnTurnout]):
58    """Manager that constructs LocoNet turnouts and tracks them by address."""
59
60    type_letter = "T"
61
62    def __init__(self, system_prefix: str, *, tc: LocoNetTrafficController) -> None:
63        super().__init__(system_prefix)
64        self._tc = tc
65        self._by_address: dict[int, LnTurnout] = {}
66        self._unsub = tc.inbound.connect(self._on_message)
67
68    def _create(self, system_name: str) -> LnTurnout:
69        address = _parse_address(system_name, self.full_prefix)
70        bean = LnTurnout(system_name, address, self._tc)
71        self._by_address[address] = bean
72        return bean
73
74    async def deregister(self, bean: LnTurnout) -> None:
75        self._by_address.pop(bean.address, None)
76        await super().deregister(bean)
77
78    async def _on_message(self, msg: LocoNetMessage) -> None:
79        if not isinstance(msg, OpcSwRep):
80            return
81        bean = self._by_address.get(msg.address)
82        if bean is None:
83            return
84        state = TurnoutState.CLOSED if msg.closed else TurnoutState.THROWN
85        await bean._set_known_state(state)

Manager that constructs LocoNet turnouts and tracks them by address.

LnTurnoutManager( system_prefix: str, *, tc: LocoNetTrafficController)
62    def __init__(self, system_prefix: str, *, tc: LocoNetTrafficController) -> None:
63        super().__init__(system_prefix)
64        self._tc = tc
65        self._by_address: dict[int, LnTurnout] = {}
66        self._unsub = tc.inbound.connect(self._on_message)
type_letter = 'T'
async def deregister(self, bean: LnTurnout) -> None:
74    async def deregister(self, bean: LnTurnout) -> None:
75        self._by_address.pop(bean.address, None)
76        await super().deregister(bean)

Remove bean from the registry. No-op if not present.

class LocoNetConnection(jmri_core.hal.connection.SystemConnection):
 53class LocoNetConnection(SystemConnection):
 54    """LocoNet hardware connection. Prefix ``L``, name ``LocoNet``."""
 55
 56    name: ClassVar[str] = "LocoNet"
 57    prefix: ClassVar[str] = "L"
 58
 59    def __init__(self) -> None:
 60        super().__init__()
 61        # These are filled in by open(). The base-class defaults stay in place
 62        # until then so the connection has well-formed managers if opened with
 63        # no settings (rare; mainly the test path).
 64        self._port: LocoNetPort | None = None
 65        self._tc: LocoNetTrafficController | None = None
 66        self._slots: SlotManager | None = None
 67        self._programmer: LnProgrammer | None = None
 68        self._cs_monitor_task: asyncio.Task[None] | None = None
 69
 70    # ----- lifecycle --------------------------------------------------------
 71
 72    async def open(self, settings: ConnectionSettings | None = None) -> None:
 73        s = settings or {}
 74        self._port = await _build_port(s)
 75        self._tc = LocoNetTrafficController(self._port)
 76        await self._tc.start()
 77        self._slots = SlotManager(self._tc)
 78
 79        # Replace the base default managers with our LocoNet-aware ones.
 80        # ``LnTurnoutManager`` and friends are subclasses of the base managers,
 81        # but mypy's invariance in attribute typing means we have to ignore
 82        # the assignment — the runtime substitution is sound (Liskov).
 83        self.turnouts = LnTurnoutManager(self.prefix, tc=self._tc)   # type: ignore[assignment]
 84        self.sensors = LnSensorManager(self.prefix, tc=self._tc)     # type: ignore[assignment]
 85        throttle_mgr = LnThrottleManager(
 86            self.prefix, tc=self._tc, slot_manager=self._slots,
 87        )
 88        self.throttles = throttle_mgr
 89        # Rebind the consist manager onto the LocoNet-aware throttle
 90        # manager. Without this, consist fan-out drives placeholder
 91        # Throttle beans left over from base ``__init__``, and no
 92        # OPC_LOCO_SPD frames reach the wire.
 93        self.consists.rebind_throttles(self.throttles)
 94        throttle_mgr.start_refresh()
 95        self.consists = LnConsistManager(   # type: ignore[assignment]
 96            throttle_mgr, self.prefix, tc=self._tc, slots=self._slots,
 97        )
 98        self.power = LnPowerManager(self._tc)
 99        self._programmer = LnProgrammer(self._tc)
100        self.programmers = LnProgrammerManager(self._programmer)
101        # Wire the advanced-consist CV19 write path to the LocoNet
102        # programmer manager. Without this rebind, ``advanced`` consists
103        # go through the base placeholder manager and CV19 never
104        # actually programs.
105        self.consists.rebind_programmers(self.programmers)
106
107        # CS reachability monitor. LocoNet CS emit OPC_IDLE and other
108        # frames continuously; 3s of complete silence is a strong
109        # signal the bus is dark (CS off or unplugged). Start
110        # pessimistic (base class default is optimistic True) so a
111        # home-page render during the monitor's startup grace shows
112        # "Off" until we've actually seen a frame, not a false "On".
113        self._cs_reachable = False
114        self._cs_monitor_task = asyncio.create_task(
115            self._cs_reachability_loop(), name="loconet-cs-reachability",
116        )
117
118        await super().open(settings)
119
120    async def _cs_reachability_loop(self) -> None:
121        """Flip ``_cs_reachable`` on the 3-second inbound-silence rule.
122
123        LocoNet's continuous chatter (CS heartbeat + slot poll traffic)
124        means any real silence past 3 seconds indicates the CS has
125        stopped talking. Any fresh inbound frame flips reachable back
126        to True immediately.
127        """
128        import contextlib  # noqa: PLC0415
129        try:
130            # 3-second grace so the first CS heartbeat can arrive.
131            await asyncio.sleep(3.0)
132            while True:
133                await asyncio.sleep(1.0)
134                if self._tc is None:
135                    return
136                last = self._tc.last_inbound_at
137                if last is None:
138                    await self._set_cs_reachable(False)
139                    continue
140                age = time.monotonic() - last
141                await self._set_cs_reachable(age <= 3.0)
142        except asyncio.CancelledError:
143            with contextlib.suppress(Exception):
144                return
145
146    async def close(self) -> None:
147        if isinstance(self.throttles, LnThrottleManager):
148            await self.throttles.stop_refresh()
149        if self._cs_monitor_task is not None:
150            self._cs_monitor_task.cancel()
151            import contextlib  # noqa: PLC0415
152            with contextlib.suppress(asyncio.CancelledError, Exception):
153                await self._cs_monitor_task
154            self._cs_monitor_task = None
155        if self._tc is not None:
156            await self._tc.aclose()
157            self._tc = None
158        self._port = None
159        await super().close()
160
161    # ----- accessors --------------------------------------------------------
162
163    @property
164    def slots(self) -> SlotManager:
165        if self._slots is None:
166            raise RuntimeError("connection not open")
167        return self._slots
168
169    @property
170    def traffic(self) -> LocoNetTrafficController:
171        if self._tc is None:
172            raise RuntimeError("connection not open")
173        return self._tc
174
175    # ----- fast clock (slot 123) --------------------------------------------
176
177    async def read_hardware_clock(self):  # noqa: ANN201
178        """Read the CS fast clock from slot 123.
179
180        Sends ``OPC_RQ_SL_DATA(slot=123)`` and awaits the paired
181        ``OPC_SL_RD_DATA`` reply on the inbound signal. Returns a
182        :class:`~jmri_core.beans.fast_clock.HardwareClockState`.
183
184        The :class:`~jmri_server.services.fast_clock.TimeService`
185        prefers this method over the software bean whenever the
186        connection exposes it, so wiring this on ``LocoNetConnection``
187        automatically makes ``/json/time`` reflect the CS clock.
188        """
189        from jmri_loconet.clock import FAST_CLOCK_SLOT, decode_slot123  # noqa: PLC0415
190        from jmri_loconet.messages import OpcRqSlData, OpcSlRdData  # noqa: PLC0415
191
192        if self._tc is None:
193            raise RuntimeError("connection not open")
194        tc = self._tc
195        loop = asyncio.get_event_loop()
196        fut: asyncio.Future[OpcSlRdData] = loop.create_future()
197
198        async def on_inbound(msg) -> None:  # noqa: ANN001
199            if (
200                isinstance(msg, OpcSlRdData)
201                and msg.slot == FAST_CLOCK_SLOT
202                and not fut.done()
203            ):
204                fut.set_result(msg)
205
206        unsub = tc.inbound.connect(on_inbound)
207        try:
208            await tc.send(OpcRqSlData(slot=FAST_CLOCK_SLOT))
209            reply = await asyncio.wait_for(fut, timeout=2.0)
210        finally:
211            unsub()
212        return decode_slot123(reply)
213
214    async def _write_hardware_clock(self, state) -> None:  # noqa: ANN001
215        """Common write path — reads the current slot to preserve
216        non-clock fields, merges *state*, and sends the write."""
217        current = await self.read_hardware_clock()
218        # ``current`` gave us the CS's authoritative days / trk / snd
219        # bytes indirectly by reading the raw slot; we lose that in
220        # the decode. To keep this pass simple we send zeros for those
221        # fields — JMRI Java's LnClockControl does the same on write,
222        # relying on the CS to preserve day count internally.
223        from jmri_loconet.clock import build_slot123_write  # noqa: PLC0415
224
225        merged = state.__class__(
226            hours=state.hours if state.hours is not None else current.hours,
227            minutes=state.minutes if state.minutes is not None else current.minutes,
228            seconds=state.seconds if state.seconds is not None else current.seconds,
229            rate=state.rate if state.rate is not None else current.rate,
230            running=state.running if state.running is not None else current.running,
231            twenty_four_hour=True,
232        )
233        msg = build_slot123_write(merged)
234        if self._tc is None:
235            raise RuntimeError("connection not open")
236        await self._tc.send(msg)
237
238    async def set_hardware_clock_time(
239        self, hours: int, minutes: int, seconds: int = 0,
240    ) -> None:
241        from jmri_core.beans.fast_clock import HardwareClockState  # noqa: PLC0415
242        current = await self.read_hardware_clock()
243        await self._write_hardware_clock(HardwareClockState(
244            hours=hours, minutes=minutes, seconds=seconds,
245            rate=current.rate, running=current.running,
246            twenty_four_hour=True,
247        ))
248
249    async def set_hardware_clock_rate(self, rate: float) -> None:
250        from jmri_core.beans.fast_clock import HardwareClockState  # noqa: PLC0415
251        current = await self.read_hardware_clock()
252        await self._write_hardware_clock(HardwareClockState(
253            hours=current.hours, minutes=current.minutes,
254            seconds=current.seconds,
255            rate=rate, running=current.running,
256            twenty_four_hour=True,
257        ))
258
259    async def start_hardware_clock(self) -> None:
260        from jmri_core.beans.fast_clock import HardwareClockState  # noqa: PLC0415
261        current = await self.read_hardware_clock()
262        # LocoNet uses rate=0 to indicate stopped, so "start" without a
263        # remembered rate defaults to 1× (real time). If the CS already
264        # has a stored non-zero rate we preserve it.
265        rate = current.rate if current.rate > 0 else 1.0
266        await self._write_hardware_clock(HardwareClockState(
267            hours=current.hours, minutes=current.minutes,
268            seconds=current.seconds,
269            rate=rate, running=True,
270            twenty_four_hour=True,
271        ))
272
273    async def stop_hardware_clock(self) -> None:
274        from jmri_core.beans.fast_clock import HardwareClockState  # noqa: PLC0415
275        current = await self.read_hardware_clock()
276        await self._write_hardware_clock(HardwareClockState(
277            hours=current.hours, minutes=current.minutes,
278            seconds=current.seconds,
279            rate=current.rate, running=False,
280            twenty_four_hour=True,
281        ))
282
283    # ----- manager overrides ------------------------------------------------
284    # We only customize turnouts, sensors, and power; everything else uses the
285    # base default in-memory managers (suitable since they don't talk to the
286    # hardware). Stage 4.5 can add LnLightManager, LnReporterManager, etc.
287
288    def _make_turnout_manager(self) -> TurnoutManager:  # pragma: no cover - overridden in open
289        return TurnoutManager(self.prefix)
290
291    # Keep all other _make_*_manager hooks at the inherited in-memory defaults.
292    # The Manager generic type-cast below is just to keep mypy happy with the
293    # in-memory defaults that aren't LocoNet-aware.
294    _GENERIC_HINT: ClassVar[type[Manager[Any]]] = LightManager
295    _OTHER_HINTS: ClassVar[tuple[type, ...]] = (
296        BlockManager,
297        MemoryManager,
298        PowerManager,
299        ReporterManager,
300        RouteManager,
301        SignalHeadManager,
302        SignalMastManager,
303    )

LocoNet hardware connection. Prefix L, name LocoNet.

name: ClassVar[str] = 'LocoNet'
prefix: ClassVar[str] = 'L'
async def open(self, settings: dict[str, typing.Any] | None = None) -> None:
 72    async def open(self, settings: ConnectionSettings | None = None) -> None:
 73        s = settings or {}
 74        self._port = await _build_port(s)
 75        self._tc = LocoNetTrafficController(self._port)
 76        await self._tc.start()
 77        self._slots = SlotManager(self._tc)
 78
 79        # Replace the base default managers with our LocoNet-aware ones.
 80        # ``LnTurnoutManager`` and friends are subclasses of the base managers,
 81        # but mypy's invariance in attribute typing means we have to ignore
 82        # the assignment — the runtime substitution is sound (Liskov).
 83        self.turnouts = LnTurnoutManager(self.prefix, tc=self._tc)   # type: ignore[assignment]
 84        self.sensors = LnSensorManager(self.prefix, tc=self._tc)     # type: ignore[assignment]
 85        throttle_mgr = LnThrottleManager(
 86            self.prefix, tc=self._tc, slot_manager=self._slots,
 87        )
 88        self.throttles = throttle_mgr
 89        # Rebind the consist manager onto the LocoNet-aware throttle
 90        # manager. Without this, consist fan-out drives placeholder
 91        # Throttle beans left over from base ``__init__``, and no
 92        # OPC_LOCO_SPD frames reach the wire.
 93        self.consists.rebind_throttles(self.throttles)
 94        throttle_mgr.start_refresh()
 95        self.consists = LnConsistManager(   # type: ignore[assignment]
 96            throttle_mgr, self.prefix, tc=self._tc, slots=self._slots,
 97        )
 98        self.power = LnPowerManager(self._tc)
 99        self._programmer = LnProgrammer(self._tc)
100        self.programmers = LnProgrammerManager(self._programmer)
101        # Wire the advanced-consist CV19 write path to the LocoNet
102        # programmer manager. Without this rebind, ``advanced`` consists
103        # go through the base placeholder manager and CV19 never
104        # actually programs.
105        self.consists.rebind_programmers(self.programmers)
106
107        # CS reachability monitor. LocoNet CS emit OPC_IDLE and other
108        # frames continuously; 3s of complete silence is a strong
109        # signal the bus is dark (CS off or unplugged). Start
110        # pessimistic (base class default is optimistic True) so a
111        # home-page render during the monitor's startup grace shows
112        # "Off" until we've actually seen a frame, not a false "On".
113        self._cs_reachable = False
114        self._cs_monitor_task = asyncio.create_task(
115            self._cs_reachability_loop(), name="loconet-cs-reachability",
116        )
117
118        await super().open(settings)

Connect to hardware and start any IO loops.

Concrete drivers acquire serial/TCP ports here, install protocol handlers, and start any required background tasks. The base implementation should be called via await super().open(settings) to mark the connection opened.

async def close(self) -> None:
146    async def close(self) -> None:
147        if isinstance(self.throttles, LnThrottleManager):
148            await self.throttles.stop_refresh()
149        if self._cs_monitor_task is not None:
150            self._cs_monitor_task.cancel()
151            import contextlib  # noqa: PLC0415
152            with contextlib.suppress(asyncio.CancelledError, Exception):
153                await self._cs_monitor_task
154            self._cs_monitor_task = None
155        if self._tc is not None:
156            await self._tc.aclose()
157            self._tc = None
158        self._port = None
159        await super().close()

Disconnect from hardware. Idempotent. Base impl flips the flag.

slots: SlotManager
163    @property
164    def slots(self) -> SlotManager:
165        if self._slots is None:
166            raise RuntimeError("connection not open")
167        return self._slots
traffic: LocoNetTrafficController
169    @property
170    def traffic(self) -> LocoNetTrafficController:
171        if self._tc is None:
172            raise RuntimeError("connection not open")
173        return self._tc
async def read_hardware_clock(self):
177    async def read_hardware_clock(self):  # noqa: ANN201
178        """Read the CS fast clock from slot 123.
179
180        Sends ``OPC_RQ_SL_DATA(slot=123)`` and awaits the paired
181        ``OPC_SL_RD_DATA`` reply on the inbound signal. Returns a
182        :class:`~jmri_core.beans.fast_clock.HardwareClockState`.
183
184        The :class:`~jmri_server.services.fast_clock.TimeService`
185        prefers this method over the software bean whenever the
186        connection exposes it, so wiring this on ``LocoNetConnection``
187        automatically makes ``/json/time`` reflect the CS clock.
188        """
189        from jmri_loconet.clock import FAST_CLOCK_SLOT, decode_slot123  # noqa: PLC0415
190        from jmri_loconet.messages import OpcRqSlData, OpcSlRdData  # noqa: PLC0415
191
192        if self._tc is None:
193            raise RuntimeError("connection not open")
194        tc = self._tc
195        loop = asyncio.get_event_loop()
196        fut: asyncio.Future[OpcSlRdData] = loop.create_future()
197
198        async def on_inbound(msg) -> None:  # noqa: ANN001
199            if (
200                isinstance(msg, OpcSlRdData)
201                and msg.slot == FAST_CLOCK_SLOT
202                and not fut.done()
203            ):
204                fut.set_result(msg)
205
206        unsub = tc.inbound.connect(on_inbound)
207        try:
208            await tc.send(OpcRqSlData(slot=FAST_CLOCK_SLOT))
209            reply = await asyncio.wait_for(fut, timeout=2.0)
210        finally:
211            unsub()
212        return decode_slot123(reply)

Read the CS fast clock from slot 123.

Sends OPC_RQ_SL_DATA(slot=123) and awaits the paired OPC_SL_RD_DATA reply on the inbound signal. Returns a ~jmri_core.beans.fast_clock.HardwareClockState.

The ~jmri_server.services.fast_clock.TimeService prefers this method over the software bean whenever the connection exposes it, so wiring this on LocoNetConnection automatically makes /json/time reflect the CS clock.

async def set_hardware_clock_time(self, hours: int, minutes: int, seconds: int = 0) -> None:
238    async def set_hardware_clock_time(
239        self, hours: int, minutes: int, seconds: int = 0,
240    ) -> None:
241        from jmri_core.beans.fast_clock import HardwareClockState  # noqa: PLC0415
242        current = await self.read_hardware_clock()
243        await self._write_hardware_clock(HardwareClockState(
244            hours=hours, minutes=minutes, seconds=seconds,
245            rate=current.rate, running=current.running,
246            twenty_four_hour=True,
247        ))
async def set_hardware_clock_rate(self, rate: float) -> None:
249    async def set_hardware_clock_rate(self, rate: float) -> None:
250        from jmri_core.beans.fast_clock import HardwareClockState  # noqa: PLC0415
251        current = await self.read_hardware_clock()
252        await self._write_hardware_clock(HardwareClockState(
253            hours=current.hours, minutes=current.minutes,
254            seconds=current.seconds,
255            rate=rate, running=current.running,
256            twenty_four_hour=True,
257        ))
async def start_hardware_clock(self) -> None:
259    async def start_hardware_clock(self) -> None:
260        from jmri_core.beans.fast_clock import HardwareClockState  # noqa: PLC0415
261        current = await self.read_hardware_clock()
262        # LocoNet uses rate=0 to indicate stopped, so "start" without a
263        # remembered rate defaults to 1× (real time). If the CS already
264        # has a stored non-zero rate we preserve it.
265        rate = current.rate if current.rate > 0 else 1.0
266        await self._write_hardware_clock(HardwareClockState(
267            hours=current.hours, minutes=current.minutes,
268            seconds=current.seconds,
269            rate=rate, running=True,
270            twenty_four_hour=True,
271        ))
async def stop_hardware_clock(self) -> None:
273    async def stop_hardware_clock(self) -> None:
274        from jmri_core.beans.fast_clock import HardwareClockState  # noqa: PLC0415
275        current = await self.read_hardware_clock()
276        await self._write_hardware_clock(HardwareClockState(
277            hours=current.hours, minutes=current.minutes,
278            seconds=current.seconds,
279            rate=current.rate, running=False,
280            twenty_four_hour=True,
281        ))
class LocoNetPort(abc.ABC):
14class LocoNetPort(ABC):
15    """Abstract bidirectional byte-stream LocoNet port."""
16
17    @abstractmethod
18    async def read_byte(self) -> int:
19        """Read one byte. Blocks until a byte is available."""
20
21    @abstractmethod
22    async def read_exact(self, n: int) -> bytes:
23        """Read exactly *n* bytes."""
24
25    @abstractmethod
26    async def write_frame(self, frame: bytes) -> None:
27        """Write a complete LocoNet frame (opcode + payload + checksum)."""
28
29    @abstractmethod
30    async def aclose(self) -> None:
31        """Close the port. Idempotent."""
32
33    @property
34    @abstractmethod
35    def is_open(self) -> bool: ...

Abstract bidirectional byte-stream LocoNet port.

@abstractmethod
async def read_byte(self) -> int:
17    @abstractmethod
18    async def read_byte(self) -> int:
19        """Read one byte. Blocks until a byte is available."""

Read one byte. Blocks until a byte is available.

@abstractmethod
async def read_exact(self, n: int) -> bytes:
21    @abstractmethod
22    async def read_exact(self, n: int) -> bytes:
23        """Read exactly *n* bytes."""

Read exactly n bytes.

@abstractmethod
async def write_frame(self, frame: bytes) -> None:
25    @abstractmethod
26    async def write_frame(self, frame: bytes) -> None:
27        """Write a complete LocoNet frame (opcode + payload + checksum)."""

Write a complete LocoNet frame (opcode + payload + checksum).

@abstractmethod
async def aclose(self) -> None:
29    @abstractmethod
30    async def aclose(self) -> None:
31        """Close the port. Idempotent."""

Close the port. Idempotent.

is_open: bool
33    @property
34    @abstractmethod
35    def is_open(self) -> bool: ...
class LocoNetTrafficController:
 27class LocoNetTrafficController:
 28    """The protocol-level conduit between transport bytes and typed messages.
 29
 30    Listeners connect to :attr:`inbound`; senders call :meth:`send`. The
 31    read loop runs as a background asyncio task started by :meth:`start`
 32    and cancelled by :meth:`aclose`.
 33    """
 34
 35    def __init__(
 36        self,
 37        port: LocoNetPort,
 38        *,
 39        min_send_interval: float = 0.005,
 40    ) -> None:
 41        self._port = port
 42        self._min_send_interval = min_send_interval
 43        self._send_lock = asyncio.Lock()
 44        self._last_send: float = 0.0
 45        self._read_task: asyncio.Task[None] | None = None
 46        self.inbound: Signal[LocoNetMessage] = Signal()
 47        self._closed = False
 48        # Timestamp (``time.monotonic()``) of the most recent inbound
 49        # frame. Read by :class:`LocoNetConnection`'s reachability
 50        # monitor to flip CS-power state. LocoNet is chatty — the CS
 51        # emits ``OPC_IDLE`` and other frames periodically — so a
 52        # multi-second silence is a strong signal the bus is dark.
 53        self.last_inbound_at: float | None = None
 54
 55    async def start(self) -> None:
 56        if self._read_task is not None:
 57            return
 58        self._read_task = asyncio.create_task(self._read_loop(), name="loconet-read")
 59
 60    async def aclose(self) -> None:
 61        self._closed = True
 62        if self._read_task is not None:
 63            self._read_task.cancel()
 64            import contextlib  # noqa: PLC0415
 65            with contextlib.suppress(asyncio.CancelledError, Exception):
 66                await self._read_task
 67            self._read_task = None
 68        await self._port.aclose()
 69
 70    async def send(self, msg: LocoNetMessage) -> None:
 71        """Encode and write *msg* to the port, with interframe pacing."""
 72        frame = encode(msg)
 73        async with self._send_lock:
 74            now = asyncio.get_event_loop().time()
 75            wait = self._min_send_interval - (now - self._last_send)
 76            if wait > 0:
 77                await asyncio.sleep(wait)
 78            await self._port.write_frame(frame)
 79            self._last_send = asyncio.get_event_loop().time()
 80
 81    # ----- internals --------------------------------------------------------
 82
 83    async def _read_loop(self) -> None:
 84        try:
 85            while not self._closed:
 86                try:
 87                    frame = await read_frame(
 88                        self._port.read_byte, self._port.read_exact
 89                    )
 90                except asyncio.CancelledError:
 91                    return
 92                except Exception as e:  # noqa: BLE001
 93                    _log.warning("LocoNet read error: %s", e)
 94                    continue
 95
 96                expected = frame_length(frame)
 97                if expected is None or expected != len(frame):
 98                    _log.warning(
 99                        "LocoNet frame length mismatch: got %d bytes for %s",
100                        len(frame), frame.hex()
101                    )
102                    continue
103
104                try:
105                    msg = decode(frame)
106                except ValueError as e:
107                    _log.warning("LocoNet bad frame %s: %s", frame.hex(), e)
108                    continue
109
110                # Any well-formed inbound frame counts as evidence the
111                # CS is alive — even a bare OPC_IDLE. The reachability
112                # monitor watches this timestamp.
113                self.last_inbound_at = time.monotonic()
114                await self.inbound.emit(msg)
115        except asyncio.CancelledError:
116            return

The protocol-level conduit between transport bytes and typed messages.

Listeners connect to inbound; senders call send(). The read loop runs as a background asyncio task started by start() and cancelled by aclose().

LocoNetTrafficController( port: LocoNetPort, *, min_send_interval: float = 0.005)
35    def __init__(
36        self,
37        port: LocoNetPort,
38        *,
39        min_send_interval: float = 0.005,
40    ) -> None:
41        self._port = port
42        self._min_send_interval = min_send_interval
43        self._send_lock = asyncio.Lock()
44        self._last_send: float = 0.0
45        self._read_task: asyncio.Task[None] | None = None
46        self.inbound: Signal[LocoNetMessage] = Signal()
47        self._closed = False
48        # Timestamp (``time.monotonic()``) of the most recent inbound
49        # frame. Read by :class:`LocoNetConnection`'s reachability
50        # monitor to flip CS-power state. LocoNet is chatty — the CS
51        # emits ``OPC_IDLE`` and other frames periodically — so a
52        # multi-second silence is a strong signal the bus is dark.
53        self.last_inbound_at: float | None = None
last_inbound_at: float | None
async def start(self) -> None:
55    async def start(self) -> None:
56        if self._read_task is not None:
57            return
58        self._read_task = asyncio.create_task(self._read_loop(), name="loconet-read")
async def aclose(self) -> None:
60    async def aclose(self) -> None:
61        self._closed = True
62        if self._read_task is not None:
63            self._read_task.cancel()
64            import contextlib  # noqa: PLC0415
65            with contextlib.suppress(asyncio.CancelledError, Exception):
66                await self._read_task
67            self._read_task = None
68        await self._port.aclose()
async def send(self, msg: jmri_loconet.messages.LocoNetMessage) -> None:
70    async def send(self, msg: LocoNetMessage) -> None:
71        """Encode and write *msg* to the port, with interframe pacing."""
72        frame = encode(msg)
73        async with self._send_lock:
74            now = asyncio.get_event_loop().time()
75            wait = self._min_send_interval - (now - self._last_send)
76            if wait > 0:
77                await asyncio.sleep(wait)
78            await self._port.write_frame(frame)
79            self._last_send = asyncio.get_event_loop().time()

Encode and write msg to the port, with interframe pacing.

class LoopbackPort(jmri_loconet.LocoNetPort):
15class LoopbackPort(LocoNetPort):
16    """One half of a loopback pair."""
17
18    def __init__(self, read_queue: asyncio.Queue[int], write_queue: asyncio.Queue[int]) -> None:
19        self._read = read_queue
20        self._write = write_queue
21        self._open = True
22
23    async def read_byte(self) -> int:
24        return await self._read.get()
25
26    async def read_exact(self, n: int) -> bytes:
27        return bytes([await self._read.get() for _ in range(n)])
28
29    async def write_frame(self, frame: bytes) -> None:
30        if not self._open:
31            raise RuntimeError("port is closed")
32        for b in frame:
33            await self._write.put(b)
34
35    async def aclose(self) -> None:
36        self._open = False
37
38    @property
39    def is_open(self) -> bool:
40        return self._open

One half of a loopback pair.

LoopbackPort( read_queue: asyncio.queues.Queue[int], write_queue: asyncio.queues.Queue[int])
18    def __init__(self, read_queue: asyncio.Queue[int], write_queue: asyncio.Queue[int]) -> None:
19        self._read = read_queue
20        self._write = write_queue
21        self._open = True
async def read_byte(self) -> int:
23    async def read_byte(self) -> int:
24        return await self._read.get()

Read one byte. Blocks until a byte is available.

async def read_exact(self, n: int) -> bytes:
26    async def read_exact(self, n: int) -> bytes:
27        return bytes([await self._read.get() for _ in range(n)])

Read exactly n bytes.

async def write_frame(self, frame: bytes) -> None:
29    async def write_frame(self, frame: bytes) -> None:
30        if not self._open:
31            raise RuntimeError("port is closed")
32        for b in frame:
33            await self._write.put(b)

Write a complete LocoNet frame (opcode + payload + checksum).

async def aclose(self) -> None:
35    async def aclose(self) -> None:
36        self._open = False

Close the port. Idempotent.

is_open: bool
38    @property
39    def is_open(self) -> bool:
40        return self._open
class SerialPort(jmri_loconet.LocoNetPort):
23class SerialPort(LocoNetPort):
24    """LocoNet over an async serial port."""
25
26    def __init__(self, reader: StreamReader, writer: StreamWriter) -> None:
27        self._reader = reader
28        self._writer = writer
29        self._open = True
30
31    @classmethod
32    async def open(cls, port: str, baud: int = DEFAULT_BAUD) -> SerialPort:
33        """Open the serial *port* (e.g. ``"COM5"`` or ``"/dev/loconet"``)."""
34        # Import lazily so the package imports cleanly on systems without
35        # pyserial installed (tests use the loopback transport).
36        import serial_asyncio  # noqa: PLC0415
37        reader, writer = await serial_asyncio.open_serial_connection(
38            url=port, baudrate=baud
39        )
40        return cls(reader=reader, writer=writer)
41
42    async def read_byte(self) -> int:
43        data = await self._reader.readexactly(1)
44        return data[0]
45
46    async def read_exact(self, n: int) -> bytes:
47        return await self._reader.readexactly(n)
48
49    async def write_frame(self, frame: bytes) -> None:
50        if not self._open:
51            raise RuntimeError("port is closed")
52        self._writer.write(frame)
53        await self._writer.drain()
54
55    async def aclose(self) -> None:
56        if not self._open:
57            return
58        self._open = False
59        self._writer.close()
60        import contextlib  # noqa: PLC0415
61        with contextlib.suppress(Exception):  # best-effort close
62            await self._writer.wait_closed()
63
64    @property
65    def is_open(self) -> bool:
66        return self._open

LocoNet over an async serial port.

SerialPort( reader: asyncio.streams.StreamReader, writer: asyncio.streams.StreamWriter)
26    def __init__(self, reader: StreamReader, writer: StreamWriter) -> None:
27        self._reader = reader
28        self._writer = writer
29        self._open = True
@classmethod
async def open( cls, port: str, baud: int = 16457) -> SerialPort:
31    @classmethod
32    async def open(cls, port: str, baud: int = DEFAULT_BAUD) -> SerialPort:
33        """Open the serial *port* (e.g. ``"COM5"`` or ``"/dev/loconet"``)."""
34        # Import lazily so the package imports cleanly on systems without
35        # pyserial installed (tests use the loopback transport).
36        import serial_asyncio  # noqa: PLC0415
37        reader, writer = await serial_asyncio.open_serial_connection(
38            url=port, baudrate=baud
39        )
40        return cls(reader=reader, writer=writer)

Open the serial port (e.g. "COM5" or "/dev/loconet").

async def read_byte(self) -> int:
42    async def read_byte(self) -> int:
43        data = await self._reader.readexactly(1)
44        return data[0]

Read one byte. Blocks until a byte is available.

async def read_exact(self, n: int) -> bytes:
46    async def read_exact(self, n: int) -> bytes:
47        return await self._reader.readexactly(n)

Read exactly n bytes.

async def write_frame(self, frame: bytes) -> None:
49    async def write_frame(self, frame: bytes) -> None:
50        if not self._open:
51            raise RuntimeError("port is closed")
52        self._writer.write(frame)
53        await self._writer.drain()

Write a complete LocoNet frame (opcode + payload + checksum).

async def aclose(self) -> None:
55    async def aclose(self) -> None:
56        if not self._open:
57            return
58        self._open = False
59        self._writer.close()
60        import contextlib  # noqa: PLC0415
61        with contextlib.suppress(Exception):  # best-effort close
62            await self._writer.wait_closed()

Close the port. Idempotent.

is_open: bool
64    @property
65    def is_open(self) -> bool:
66        return self._open
@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: 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)
class TcpPort(jmri_loconet.LocoNetPort):
14class TcpPort(LocoNetPort):
15    """LocoNet over a TCP socket (e.g. JMRI's LbServer or Digitrax LNWI)."""
16
17    def __init__(self, reader: StreamReader, writer: StreamWriter) -> None:
18        self._reader = reader
19        self._writer = writer
20        self._open = True
21
22    @classmethod
23    async def open(cls, host: str, port: int) -> TcpPort:
24        reader, writer = await asyncio.open_connection(host, port)
25        return cls(reader=reader, writer=writer)
26
27    async def read_byte(self) -> int:
28        data = await self._reader.readexactly(1)
29        return data[0]
30
31    async def read_exact(self, n: int) -> bytes:
32        return await self._reader.readexactly(n)
33
34    async def write_frame(self, frame: bytes) -> None:
35        if not self._open:
36            raise RuntimeError("port is closed")
37        self._writer.write(frame)
38        await self._writer.drain()
39
40    async def aclose(self) -> None:
41        if not self._open:
42            return
43        self._open = False
44        self._writer.close()
45        import contextlib  # noqa: PLC0415
46        with contextlib.suppress(Exception):
47            await self._writer.wait_closed()
48
49    @property
50    def is_open(self) -> bool:
51        return self._open

LocoNet over a TCP socket (e.g. JMRI's LbServer or Digitrax LNWI).

TcpPort( reader: asyncio.streams.StreamReader, writer: asyncio.streams.StreamWriter)
17    def __init__(self, reader: StreamReader, writer: StreamWriter) -> None:
18        self._reader = reader
19        self._writer = writer
20        self._open = True
@classmethod
async def open(cls, host: str, port: int) -> TcpPort:
22    @classmethod
23    async def open(cls, host: str, port: int) -> TcpPort:
24        reader, writer = await asyncio.open_connection(host, port)
25        return cls(reader=reader, writer=writer)
async def read_byte(self) -> int:
27    async def read_byte(self) -> int:
28        data = await self._reader.readexactly(1)
29        return data[0]

Read one byte. Blocks until a byte is available.

async def read_exact(self, n: int) -> bytes:
31    async def read_exact(self, n: int) -> bytes:
32        return await self._reader.readexactly(n)

Read exactly n bytes.

async def write_frame(self, frame: bytes) -> None:
34    async def write_frame(self, frame: bytes) -> None:
35        if not self._open:
36            raise RuntimeError("port is closed")
37        self._writer.write(frame)
38        await self._writer.drain()

Write a complete LocoNet frame (opcode + payload + checksum).

async def aclose(self) -> None:
40    async def aclose(self) -> None:
41        if not self._open:
42            return
43        self._open = False
44        self._writer.close()
45        import contextlib  # noqa: PLC0415
46        with contextlib.suppress(Exception):
47            await self._writer.wait_closed()

Close the port. Idempotent.

is_open: bool
49    @property
50    def is_open(self) -> bool:
51        return self._open
__version__ = '0.1.0'
def loopback_pair() -> tuple[LoopbackPort, LoopbackPort]:
43def loopback_pair() -> tuple[LoopbackPort, LoopbackPort]:
44    """Return a pair of connected loopback ports."""
45    a_to_b: asyncio.Queue[int] = asyncio.Queue()
46    b_to_a: asyncio.Queue[int] = asyncio.Queue()
47    a = LoopbackPort(read_queue=b_to_a, write_queue=a_to_b)
48    b = LoopbackPort(read_queue=a_to_b, write_queue=b_to_a)
49    return a, b

Return a pair of connected loopback ports.