jmri_loconet.transport
Transport layer — byte-stream abstractions for LocoNet.
1"""Transport layer — byte-stream abstractions for LocoNet.""" 2from jmri_loconet.transport.loopback import LoopbackPort, loopback_pair 3from jmri_loconet.transport.port import LocoNetPort 4from jmri_loconet.transport.serial_port import SerialPort 5from jmri_loconet.transport.tcp_port import TcpPort 6 7__all__ = ["LocoNetPort", "LoopbackPort", "SerialPort", "TcpPort", "loopback_pair"]
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
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).
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.
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).
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.
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
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.
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).
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).
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.