jmri_openlcb.memory

OpenLCB Memory Configuration Protocol (S-9.7.4.2).

Memory Config sits on top of the datagram layer. Every request has a 1-byte protocol id (0x20 = Memory Configuration), a 1-byte command, a 4-byte address, and a variable-length data / count field. Replies mirror the request shape with a success/failure bit set in the command byte.

Command byte layout::

bits 6..7  = category (00 read, 01 write, 10 configuration)
bits 4..5  = 0 for read/write; other values for config commands
bit 3      = 0 = success reply, 1 = failure reply
             (only meaningful on reply frames)
bits 0..2  = space info; the low three bits encode one of the
             well-known spaces (0xFD..0xFF → 5..7) when we use
             the compact form, or 0 to signal "explicit space
             byte follows the address" for generic addressing.

We ship the compact form for the three commonly-used spaces (CDI = 0xFF, All = 0xFD, Config = 0xFE) and the generic form for anything else.

Reads are capped at 64 bytes per request by the protocol; larger transfers are split into 64-byte chunks by MemoryClient.read(). Writes obey the datagram cap (72 bytes total minus 6/7 bytes of header → ~64 bytes of payload); the client fragments similarly.

  1"""OpenLCB Memory Configuration Protocol (S-9.7.4.2).
  2
  3Memory Config sits on top of the datagram layer. Every request has a
  41-byte protocol id (``0x20`` = Memory Configuration), a 1-byte
  5command, a 4-byte address, and a variable-length data / count field.
  6Replies mirror the request shape with a success/failure bit set in
  7the command byte.
  8
  9Command byte layout::
 10
 11    bits 6..7  = category (00 read, 01 write, 10 configuration)
 12    bits 4..5  = 0 for read/write; other values for config commands
 13    bit 3      = 0 = success reply, 1 = failure reply
 14                 (only meaningful on reply frames)
 15    bits 0..2  = space info; the low three bits encode one of the
 16                 well-known spaces (0xFD..0xFF → 5..7) when we use
 17                 the compact form, or 0 to signal "explicit space
 18                 byte follows the address" for generic addressing.
 19
 20We ship the compact form for the three commonly-used spaces
 21(``CDI = 0xFF``, ``All = 0xFD``, ``Config = 0xFE``) and the
 22generic form for anything else.
 23
 24Reads are capped at 64 bytes per request by the protocol; larger
 25transfers are split into 64-byte chunks by :meth:`MemoryClient.read`.
 26Writes obey the datagram cap (72 bytes total minus 6/7 bytes of
 27header → ~64 bytes of payload); the client fragments similarly.
 28"""
 29from __future__ import annotations
 30
 31import asyncio
 32import logging
 33from dataclasses import dataclass
 34from typing import TYPE_CHECKING
 35
 36from jmri_openlcb.datagrams import Datagram, DatagramEngine
 37
 38if TYPE_CHECKING:
 39    pass
 40
 41_log = logging.getLogger(__name__)
 42
 43# ---- constants ---------------------------------------------------------
 44
 45MEMORY_CONFIG = 0x20   # protocol id shared by every request/reply
 46
 47# Well-known memory spaces per S-9.7.4.2.
 48SPACE_CDI = 0xFF
 49SPACE_ALL = 0xFD
 50SPACE_CONFIG = 0xFE
 51
 52# Read/write are capped by the datagram size (72 bytes) minus the
 53# protocol/command/address header (6 bytes for compact-space form,
 54# 7 bytes for explicit-space form). 64 bytes is the conservative
 55# maximum every node must honour.
 56MAX_MEMORY_CHUNK = 64
 57
 58
 59# ---- command-byte helpers ----------------------------------------------
 60
 61
 62def _compact_space_bits(space: int) -> int | None:
 63    """Compact-form encoding maps 0xFD → 5, 0xFE → 6, 0xFF → 7.
 64
 65    Any other space uses the generic form (explicit space byte after
 66    the address); returns ``None`` in that case.
 67    """
 68    if space == 0xFD:
 69        return 0b101
 70    if space == 0xFE:
 71        return 0b110
 72    if space == 0xFF:
 73        return 0b111
 74    return None
 75
 76
 77def _encode_read_command(space: int) -> tuple[int, bool]:
 78    """Return ``(command_byte, uses_explicit_space)`` for a read
 79    request targeting *space*."""
 80    compact = _compact_space_bits(space)
 81    if compact is not None:
 82        return (0b01000000 | compact, False)
 83    return (0b01000000, True)
 84
 85
 86def _encode_write_command(space: int) -> tuple[int, bool]:
 87    compact = _compact_space_bits(space)
 88    if compact is not None:
 89        return (0b00000000 | compact, False)
 90    return (0b00000000, True)
 91
 92
 93def _is_read_reply(command: int) -> bool:
 94    return (command & 0xF0) == 0x50
 95
 96
 97def _is_write_reply(command: int) -> bool:
 98    return (command & 0xF0) == 0x10
 99
100
101def _is_failure_reply(command: int) -> bool:
102    return bool(command & 0x08)
103
104
105# ---- exceptions --------------------------------------------------------
106
107
108@dataclass(frozen=True, slots=True)
109class MemoryError_(Exception):
110    """Raised when a node returns a failure reply for a memory op.
111
112    Name has an underscore so it doesn't shadow Python's built-in.
113    """
114
115    error_code: int
116
117    def __str__(self) -> str:
118        return f"memory operation failed (code {self.error_code:#06x})"
119
120
121# ---- client ------------------------------------------------------------
122
123
124class MemoryClient:
125    """Convenience API over the memory-config protocol.
126
127    Wraps a :class:`~jmri_openlcb.datagrams.DatagramEngine` so callers
128    talk to remote nodes in terms of ``read(space, address, count)`` /
129    ``write(space, address, data)`` and get bytes / None back
130    respectively. Failures raise :class:`MemoryError_`.
131
132    Each client instance registers ONE inbound listener on the
133    datagram engine and dispatches incoming replies to whatever
134    request is waiting. That means callers can await multiple
135    read/write operations against the same peer serially — a single
136    outstanding operation per peer is the assumption (matches the
137    protocol's implicit half-duplex nature at the memory layer).
138    """
139
140    def __init__(self, engine: DatagramEngine) -> None:
141        self._engine = engine
142        self._pending: dict[int, asyncio.Future[bytes]] = {}
143        self._unsubscribe = engine.inbound.connect(self._on_inbound)
144
145    def close(self) -> None:
146        self._unsubscribe()
147        for fut in self._pending.values():
148            if not fut.done():
149                fut.set_exception(RuntimeError("memory client closed"))
150        self._pending.clear()
151
152    async def read(
153        self,
154        peer_alias: int,
155        space: int,
156        address: int,
157        count: int,
158        *,
159        timeout: float = 3.0,
160    ) -> bytes:
161        """Read *count* bytes starting at *address* in *space* from
162        the peer. Automatically splits reads over
163        :data:`MAX_MEMORY_CHUNK` into multiple requests.
164        """
165        if count < 0:
166            raise ValueError(f"count must be non-negative: {count}")
167        chunks: list[bytes] = []
168        remaining = count
169        cursor = address
170        while remaining > 0:
171            batch = min(remaining, MAX_MEMORY_CHUNK)
172            chunk = await self._read_one(
173                peer_alias, space, cursor, batch, timeout=timeout,
174            )
175            chunks.append(chunk)
176            # A short reply signals end-of-space; stop early so we
177            # don't spin at the peer's expense.
178            if len(chunk) < batch:
179                break
180            cursor += batch
181            remaining -= batch
182        return b"".join(chunks)
183
184    async def write(
185        self,
186        peer_alias: int,
187        space: int,
188        address: int,
189        data: bytes,
190        *,
191        timeout: float = 3.0,
192    ) -> None:
193        """Write *data* starting at *address* in *space* on the peer.
194        Split into :data:`MAX_MEMORY_CHUNK`-byte writes."""
195        cursor = address
196        pos = 0
197        while pos < len(data):
198            batch = data[pos:pos + MAX_MEMORY_CHUNK]
199            await self._write_one(
200                peer_alias, space, cursor, batch, timeout=timeout,
201            )
202            cursor += len(batch)
203            pos += len(batch)
204
205    # ---- one-shot request path ----------------------------------------
206
207    async def _read_one(
208        self, peer_alias: int, space: int, address: int, count: int,
209        *, timeout: float,
210    ) -> bytes:
211        cmd, explicit = _encode_read_command(space)
212        body = self._build_request(cmd, address, explicit, space)
213        # count byte last; single byte, 1..64.
214        body += bytes([count & 0xFF])
215        return await self._request(peer_alias, body, timeout=timeout)
216
217    async def _write_one(
218        self, peer_alias: int, space: int, address: int, data: bytes,
219        *, timeout: float,
220    ) -> None:
221        cmd, explicit = _encode_write_command(space)
222        body = self._build_request(cmd, address, explicit, space) + data
223        await self._request(peer_alias, body, timeout=timeout)
224
225    def _build_request(
226        self, command: int, address: int, explicit_space: bool, space: int,
227    ) -> bytes:
228        addr_bytes = address.to_bytes(4, "big")
229        body = bytes([MEMORY_CONFIG, command]) + addr_bytes
230        if explicit_space:
231            body += bytes([space & 0xFF])
232        return body
233
234    async def _request(
235        self, peer_alias: int, body: bytes, *, timeout: float,
236    ) -> bytes:
237        """Send *body* as a datagram and await the peer's memory-config
238        reply (which arrives as an inbound datagram after the peer's
239        Received-OK)."""
240        if peer_alias in self._pending:
241            raise RuntimeError(
242                f"a memory op to alias {peer_alias:#x} is already in flight",
243            )
244        loop = asyncio.get_event_loop()
245        fut: asyncio.Future[bytes] = loop.create_future()
246        self._pending[peer_alias] = fut
247        try:
248            # The datagram engine's send() awaits Received OK before
249            # returning; the peer's memory-config *reply* arrives as
250            # a subsequent inbound datagram.
251            await self._engine.send(peer_alias, body, timeout=timeout)
252            data = await asyncio.wait_for(fut, timeout=timeout)
253        finally:
254            self._pending.pop(peer_alias, None)
255        return data
256
257    # ---- inbound reply handler ----------------------------------------
258
259    async def _on_inbound(self, dg: Datagram) -> None:
260        if len(dg.data) < 6:
261            return
262        if dg.data[0] != MEMORY_CONFIG:
263            return
264        command = dg.data[1]
265        # We match on peer alias (source of the datagram).
266        fut = self._pending.get(dg.source_alias)
267        if fut is None or fut.done():
268            return
269        if _is_failure_reply(command):
270            code = 0
271            # Optional 2-byte error code follows the address (6 bytes in).
272            if len(dg.data) >= 8:
273                code = (dg.data[6] << 8) | dg.data[7]
274            fut.set_exception(MemoryError_(error_code=code))
275            return
276        if _is_read_reply(command):
277            # Layout: proto, cmd, addr(4), [space], data...
278            # Compact spaces skip the extra space byte.
279            compact = _compact_space_bits_from_command(command)
280            data_start = 6 if compact is not None else 7
281            fut.set_result(bytes(dg.data[data_start:]))
282            return
283        if _is_write_reply(command):
284            # Write success carries no body; empty bytes signals done.
285            fut.set_result(b"")
286
287
288def _compact_space_bits_from_command(command: int) -> int | None:
289    """Decode the low 3 bits of a reply command byte back into a
290    space number, or ``None`` for generic (explicit-space) form."""
291    space_bits = command & 0b111
292    if space_bits == 0b101:
293        return 0xFD
294    if space_bits == 0b110:
295        return 0xFE
296    if space_bits == 0b111:
297        return 0xFF
298    return None
MEMORY_CONFIG = 32
SPACE_CDI = 255
SPACE_ALL = 253
SPACE_CONFIG = 254
MAX_MEMORY_CHUNK = 64
@dataclass(frozen=True, slots=True)
class MemoryError_(builtins.Exception):
109@dataclass(frozen=True, slots=True)
110class MemoryError_(Exception):
111    """Raised when a node returns a failure reply for a memory op.
112
113    Name has an underscore so it doesn't shadow Python's built-in.
114    """
115
116    error_code: int
117
118    def __str__(self) -> str:
119        return f"memory operation failed (code {self.error_code:#06x})"

Raised when a node returns a failure reply for a memory op.

Name has an underscore so it doesn't shadow Python's built-in.

MemoryError_(error_code: int)
error_code: int
class MemoryClient:
125class MemoryClient:
126    """Convenience API over the memory-config protocol.
127
128    Wraps a :class:`~jmri_openlcb.datagrams.DatagramEngine` so callers
129    talk to remote nodes in terms of ``read(space, address, count)`` /
130    ``write(space, address, data)`` and get bytes / None back
131    respectively. Failures raise :class:`MemoryError_`.
132
133    Each client instance registers ONE inbound listener on the
134    datagram engine and dispatches incoming replies to whatever
135    request is waiting. That means callers can await multiple
136    read/write operations against the same peer serially — a single
137    outstanding operation per peer is the assumption (matches the
138    protocol's implicit half-duplex nature at the memory layer).
139    """
140
141    def __init__(self, engine: DatagramEngine) -> None:
142        self._engine = engine
143        self._pending: dict[int, asyncio.Future[bytes]] = {}
144        self._unsubscribe = engine.inbound.connect(self._on_inbound)
145
146    def close(self) -> None:
147        self._unsubscribe()
148        for fut in self._pending.values():
149            if not fut.done():
150                fut.set_exception(RuntimeError("memory client closed"))
151        self._pending.clear()
152
153    async def read(
154        self,
155        peer_alias: int,
156        space: int,
157        address: int,
158        count: int,
159        *,
160        timeout: float = 3.0,
161    ) -> bytes:
162        """Read *count* bytes starting at *address* in *space* from
163        the peer. Automatically splits reads over
164        :data:`MAX_MEMORY_CHUNK` into multiple requests.
165        """
166        if count < 0:
167            raise ValueError(f"count must be non-negative: {count}")
168        chunks: list[bytes] = []
169        remaining = count
170        cursor = address
171        while remaining > 0:
172            batch = min(remaining, MAX_MEMORY_CHUNK)
173            chunk = await self._read_one(
174                peer_alias, space, cursor, batch, timeout=timeout,
175            )
176            chunks.append(chunk)
177            # A short reply signals end-of-space; stop early so we
178            # don't spin at the peer's expense.
179            if len(chunk) < batch:
180                break
181            cursor += batch
182            remaining -= batch
183        return b"".join(chunks)
184
185    async def write(
186        self,
187        peer_alias: int,
188        space: int,
189        address: int,
190        data: bytes,
191        *,
192        timeout: float = 3.0,
193    ) -> None:
194        """Write *data* starting at *address* in *space* on the peer.
195        Split into :data:`MAX_MEMORY_CHUNK`-byte writes."""
196        cursor = address
197        pos = 0
198        while pos < len(data):
199            batch = data[pos:pos + MAX_MEMORY_CHUNK]
200            await self._write_one(
201                peer_alias, space, cursor, batch, timeout=timeout,
202            )
203            cursor += len(batch)
204            pos += len(batch)
205
206    # ---- one-shot request path ----------------------------------------
207
208    async def _read_one(
209        self, peer_alias: int, space: int, address: int, count: int,
210        *, timeout: float,
211    ) -> bytes:
212        cmd, explicit = _encode_read_command(space)
213        body = self._build_request(cmd, address, explicit, space)
214        # count byte last; single byte, 1..64.
215        body += bytes([count & 0xFF])
216        return await self._request(peer_alias, body, timeout=timeout)
217
218    async def _write_one(
219        self, peer_alias: int, space: int, address: int, data: bytes,
220        *, timeout: float,
221    ) -> None:
222        cmd, explicit = _encode_write_command(space)
223        body = self._build_request(cmd, address, explicit, space) + data
224        await self._request(peer_alias, body, timeout=timeout)
225
226    def _build_request(
227        self, command: int, address: int, explicit_space: bool, space: int,
228    ) -> bytes:
229        addr_bytes = address.to_bytes(4, "big")
230        body = bytes([MEMORY_CONFIG, command]) + addr_bytes
231        if explicit_space:
232            body += bytes([space & 0xFF])
233        return body
234
235    async def _request(
236        self, peer_alias: int, body: bytes, *, timeout: float,
237    ) -> bytes:
238        """Send *body* as a datagram and await the peer's memory-config
239        reply (which arrives as an inbound datagram after the peer's
240        Received-OK)."""
241        if peer_alias in self._pending:
242            raise RuntimeError(
243                f"a memory op to alias {peer_alias:#x} is already in flight",
244            )
245        loop = asyncio.get_event_loop()
246        fut: asyncio.Future[bytes] = loop.create_future()
247        self._pending[peer_alias] = fut
248        try:
249            # The datagram engine's send() awaits Received OK before
250            # returning; the peer's memory-config *reply* arrives as
251            # a subsequent inbound datagram.
252            await self._engine.send(peer_alias, body, timeout=timeout)
253            data = await asyncio.wait_for(fut, timeout=timeout)
254        finally:
255            self._pending.pop(peer_alias, None)
256        return data
257
258    # ---- inbound reply handler ----------------------------------------
259
260    async def _on_inbound(self, dg: Datagram) -> None:
261        if len(dg.data) < 6:
262            return
263        if dg.data[0] != MEMORY_CONFIG:
264            return
265        command = dg.data[1]
266        # We match on peer alias (source of the datagram).
267        fut = self._pending.get(dg.source_alias)
268        if fut is None or fut.done():
269            return
270        if _is_failure_reply(command):
271            code = 0
272            # Optional 2-byte error code follows the address (6 bytes in).
273            if len(dg.data) >= 8:
274                code = (dg.data[6] << 8) | dg.data[7]
275            fut.set_exception(MemoryError_(error_code=code))
276            return
277        if _is_read_reply(command):
278            # Layout: proto, cmd, addr(4), [space], data...
279            # Compact spaces skip the extra space byte.
280            compact = _compact_space_bits_from_command(command)
281            data_start = 6 if compact is not None else 7
282            fut.set_result(bytes(dg.data[data_start:]))
283            return
284        if _is_write_reply(command):
285            # Write success carries no body; empty bytes signals done.
286            fut.set_result(b"")

Convenience API over the memory-config protocol.

Wraps a ~jmri_openlcb.datagrams.DatagramEngine so callers talk to remote nodes in terms of read(space, address, count) / write(space, address, data) and get bytes / None back respectively. Failures raise MemoryError_.

Each client instance registers ONE inbound listener on the datagram engine and dispatches incoming replies to whatever request is waiting. That means callers can await multiple read/write operations against the same peer serially — a single outstanding operation per peer is the assumption (matches the protocol's implicit half-duplex nature at the memory layer).

MemoryClient(engine: jmri_openlcb.DatagramEngine)
141    def __init__(self, engine: DatagramEngine) -> None:
142        self._engine = engine
143        self._pending: dict[int, asyncio.Future[bytes]] = {}
144        self._unsubscribe = engine.inbound.connect(self._on_inbound)
def close(self) -> None:
146    def close(self) -> None:
147        self._unsubscribe()
148        for fut in self._pending.values():
149            if not fut.done():
150                fut.set_exception(RuntimeError("memory client closed"))
151        self._pending.clear()
async def read( self, peer_alias: int, space: int, address: int, count: int, *, timeout: float = 3.0) -> bytes:
153    async def read(
154        self,
155        peer_alias: int,
156        space: int,
157        address: int,
158        count: int,
159        *,
160        timeout: float = 3.0,
161    ) -> bytes:
162        """Read *count* bytes starting at *address* in *space* from
163        the peer. Automatically splits reads over
164        :data:`MAX_MEMORY_CHUNK` into multiple requests.
165        """
166        if count < 0:
167            raise ValueError(f"count must be non-negative: {count}")
168        chunks: list[bytes] = []
169        remaining = count
170        cursor = address
171        while remaining > 0:
172            batch = min(remaining, MAX_MEMORY_CHUNK)
173            chunk = await self._read_one(
174                peer_alias, space, cursor, batch, timeout=timeout,
175            )
176            chunks.append(chunk)
177            # A short reply signals end-of-space; stop early so we
178            # don't spin at the peer's expense.
179            if len(chunk) < batch:
180                break
181            cursor += batch
182            remaining -= batch
183        return b"".join(chunks)

Read count bytes starting at address in space from the peer. Automatically splits reads over MAX_MEMORY_CHUNK into multiple requests.

async def write( self, peer_alias: int, space: int, address: int, data: bytes, *, timeout: float = 3.0) -> None:
185    async def write(
186        self,
187        peer_alias: int,
188        space: int,
189        address: int,
190        data: bytes,
191        *,
192        timeout: float = 3.0,
193    ) -> None:
194        """Write *data* starting at *address* in *space* on the peer.
195        Split into :data:`MAX_MEMORY_CHUNK`-byte writes."""
196        cursor = address
197        pos = 0
198        while pos < len(data):
199            batch = data[pos:pos + MAX_MEMORY_CHUNK]
200            await self._write_one(
201                peer_alias, space, cursor, batch, timeout=timeout,
202            )
203            cursor += len(batch)
204            pos += len(batch)

Write data starting at address in space on the peer. Split into MAX_MEMORY_CHUNK-byte writes.