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
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.
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).
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.
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.