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 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 LoopbackPort(jmri_loconet.transport.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.transport.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
class TcpPort(jmri_loconet.transport.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
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.