jmri_loconet.messages
Typed LocoNet message dataclasses + encode/decode dispatch.
Each opcode JMRI's LnConstants declares maps to one frozen dataclass.
decode() turns frame bytes into the matching dataclass;
encode() does the reverse (returns frame bytes including checksum).
Unrecognized but well-formed frames decode to OpcUnknown so
listeners can still observe them — JMRI exposes everything on the bus.
1"""Typed LocoNet message dataclasses + encode/decode dispatch. 2 3Each opcode JMRI's ``LnConstants`` declares maps to one frozen dataclass. 4:func:`decode` turns frame bytes into the matching dataclass; 5:func:`encode` does the reverse (returns frame bytes including checksum). 6 7Unrecognized but well-formed frames decode to :class:`OpcUnknown` so 8listeners can still observe them — JMRI exposes everything on the bus. 9""" 10from __future__ import annotations 11 12from dataclasses import dataclass 13 14from jmri_loconet.codec import append_checksum, compute_checksum, verify_checksum 15from jmri_loconet.opcodes import ( 16 OPC_GPOFF, 17 OPC_GPON, 18 OPC_IDLE, 19 OPC_INPUT_REP, 20 OPC_LINK_SLOTS, 21 OPC_LOCO_ADR, 22 OPC_LOCO_DIRF, 23 OPC_LOCO_SND, 24 OPC_LOCO_SPD, 25 OPC_LONG_ACK, 26 OPC_MOVE_SLOTS, 27 OPC_RQ_SL_DATA, 28 OPC_SL_RD_DATA, 29 OPC_SLOT_STAT1, 30 OPC_SW_REP, 31 OPC_SW_REQ, 32 OPC_UNLINK_SLOTS, 33 OPC_WR_SL_DATA, 34) 35 36# ---------- base + unknown --------------------------------------------------- 37 38 39class LocoNetMessage: 40 """Marker base type. Subclasses are frozen dataclasses.""" 41 opcode: int 42 43 44@dataclass(frozen=True, slots=True) 45class OpcUnknown(LocoNetMessage): 46 """A well-formed frame whose opcode we don't have a typed wrapper for.""" 47 opcode: int 48 payload: bytes # everything between opcode and checksum (exclusive) 49 50 51# ---------- 2-byte messages -------------------------------------------------- 52 53 54@dataclass(frozen=True, slots=True) 55class OpcGpOn(LocoNetMessage): 56 opcode: int = OPC_GPON 57 58 59@dataclass(frozen=True, slots=True) 60class OpcGpOff(LocoNetMessage): 61 opcode: int = OPC_GPOFF 62 63 64@dataclass(frozen=True, slots=True) 65class OpcIdle(LocoNetMessage): 66 opcode: int = OPC_IDLE 67 68 69# ---------- 4-byte messages -------------------------------------------------- 70 71 72@dataclass(frozen=True, slots=True) 73class OpcSwReq(LocoNetMessage): 74 """Switch request — throw or close a turnout. 75 76 Address is 1..2048 (LocoNet packs it across two bytes with bit layout 77 that JMRI documents explicitly). 78 """ 79 address: int 80 closed: bool # True = closed direction, False = thrown 81 output_on: bool # True = energize, False = de-energize 82 opcode: int = OPC_SW_REQ 83 84 85@dataclass(frozen=True, slots=True) 86class OpcSwRep(LocoNetMessage): 87 """Switch report — turnout state feedback from the layout.""" 88 address: int 89 closed: bool 90 thrown: bool 91 opcode: int = OPC_SW_REP 92 93 94@dataclass(frozen=True, slots=True) 95class OpcInputRep(LocoNetMessage): 96 """Sensor input report.""" 97 address: int 98 active: bool 99 opcode: int = OPC_INPUT_REP 100 101 102@dataclass(frozen=True, slots=True) 103class OpcLongAck(LocoNetMessage): 104 """Long-form acknowledgment — sent in response to many request opcodes.""" 105 responding_opcode: int 106 ack_code: int 107 opcode: int = OPC_LONG_ACK 108 109 110@dataclass(frozen=True, slots=True) 111class OpcSlotStat1(LocoNetMessage): 112 slot: int 113 stat: int 114 opcode: int = OPC_SLOT_STAT1 115 116 117@dataclass(frozen=True, slots=True) 118class OpcMoveSlots(LocoNetMessage): 119 """Move slot src → dest. Same src+dest=null move (refresh); 0=dispatch.""" 120 src: int 121 dest: int 122 opcode: int = OPC_MOVE_SLOTS 123 124 125@dataclass(frozen=True, slots=True) 126class OpcLinkSlots(LocoNetMessage): 127 """Link ``src`` as a follower of ``dest`` — build a consist chain. 128 129 After a LINK, the command station broadcasts speed and direction 130 commands from *dest* to *src* automatically; the client only needs 131 to drive the lead loco. Functions remain per-loco. 132 """ 133 src: int 134 dest: int 135 opcode: int = OPC_LINK_SLOTS 136 137 138@dataclass(frozen=True, slots=True) 139class OpcUnlinkSlots(LocoNetMessage): 140 """Break the LINK between *src* and *dest*. 141 142 Ordering: *dest* is the slot higher in the chain, *src* is the 143 follower being detached. After UNLINK, *src* is a standalone slot 144 again and stops receiving speed/direction fan-out from *dest*. 145 """ 146 src: int 147 dest: int 148 opcode: int = OPC_UNLINK_SLOTS 149 150 151@dataclass(frozen=True, slots=True) 152class OpcRqSlData(LocoNetMessage): 153 """Request slot data — server replies with OpcSlRdData.""" 154 slot: int 155 opcode: int = OPC_RQ_SL_DATA 156 157 158@dataclass(frozen=True, slots=True) 159class OpcLocoAdr(LocoNetMessage): 160 """Request the slot currently holding *address*. 161 162 Long addresses use the high byte; short addresses set high=0. 163 """ 164 address: int 165 opcode: int = OPC_LOCO_ADR 166 167 168@dataclass(frozen=True, slots=True) 169class OpcLocoSpd(LocoNetMessage): 170 """Set speed for a slot. ``speed`` is 0..127.""" 171 slot: int 172 speed: int 173 opcode: int = OPC_LOCO_SPD 174 175 176@dataclass(frozen=True, slots=True) 177class OpcLocoDirF(LocoNetMessage): 178 """Set direction + F0..F4 for a slot. 179 180 ``dirf`` is the wire byte: bit 5 = direction (0 = forward), bits 0..4 = F0..F4. 181 """ 182 slot: int 183 dirf: int 184 opcode: int = OPC_LOCO_DIRF 185 186 187@dataclass(frozen=True, slots=True) 188class OpcLocoSnd(LocoNetMessage): 189 """Set F5..F8 for a slot. ``snd`` packs F5..F8 in bits 0..3.""" 190 slot: int 191 snd: int 192 opcode: int = OPC_LOCO_SND 193 194 195# ---------- variable-length messages ----------------------------------------- 196 197 198@dataclass(frozen=True, slots=True) 199class OpcSlRdData(LocoNetMessage): 200 """Slot read data — 14-byte response carrying full slot state. 201 202 ``ss2`` (byte index 8 on the wire) holds the "STAT2" byte for 203 regular slots, and the encoded hours field for the fast-clock 204 slot (123). It defaults to 0 so existing callers don't need to 205 care; the clock module fills it in. 206 """ 207 slot: int 208 stat: int 209 address: int # composed from ADR + ADR2 210 speed: int 211 dirf: int 212 trk: int 213 snd: int 214 ss2: int = 0 215 opcode: int = OPC_SL_RD_DATA 216 217 218@dataclass(frozen=True, slots=True) 219class OpcWrSlData(LocoNetMessage): 220 """Write slot data — same shape as OpcSlRdData but client → master.""" 221 slot: int 222 stat: int 223 address: int 224 speed: int 225 dirf: int 226 trk: int 227 snd: int 228 ss2: int = 0 229 opcode: int = OPC_WR_SL_DATA 230 231 232# ---------- decode ----------------------------------------------------------- 233 234 235def decode(frame: bytes) -> LocoNetMessage: 236 """Turn a complete, checksum-verified frame into a typed message. 237 238 Raises :class:`ValueError` if the checksum is invalid. 239 """ 240 if not verify_checksum(frame): 241 raise ValueError("LocoNet frame has invalid checksum") 242 op = frame[0] 243 244 # 2-byte 245 if op == OPC_GPON: 246 return OpcGpOn() 247 if op == OPC_GPOFF: 248 return OpcGpOff() 249 if op == OPC_IDLE: 250 return OpcIdle() 251 252 # 4-byte: layout is [op, b1, b2, cs]; b1/b2 vary per opcode. 253 if op == OPC_SW_REQ: 254 return OpcSwReq( 255 address=_decode_switch_address(frame[1], frame[2]), 256 closed=bool(frame[2] & 0x20), 257 output_on=bool(frame[2] & 0x10), 258 ) 259 if op == OPC_SW_REP: 260 return OpcSwRep( 261 address=_decode_switch_address(frame[1], frame[2]), 262 closed=bool(frame[2] & 0x20), 263 thrown=not bool(frame[2] & 0x20), 264 ) 265 if op == OPC_INPUT_REP: 266 # JMRI's mapping: 267 # pair = ((SN2 & 0x0F) << 7) | (SN1 & 0x7F) 268 # sensor (1-based) = pair * 2 + ((SN2 & 0x20) >> 5) + 1 269 pair = ((frame[2] & 0x0F) << 7) | (frame[1] & 0x7F) 270 sensor_addr = (pair * 2) + ((frame[2] & 0x20) >> 5) + 1 271 active = bool(frame[2] & 0x10) 272 return OpcInputRep(address=sensor_addr, active=active) 273 if op == OPC_LONG_ACK: 274 return OpcLongAck(responding_opcode=frame[1] | 0x80, ack_code=frame[2]) 275 if op == OPC_SLOT_STAT1: 276 return OpcSlotStat1(slot=frame[1], stat=frame[2]) 277 if op == OPC_MOVE_SLOTS: 278 return OpcMoveSlots(src=frame[1], dest=frame[2]) 279 if op == OPC_LINK_SLOTS: 280 return OpcLinkSlots(src=frame[1], dest=frame[2]) 281 if op == OPC_UNLINK_SLOTS: 282 return OpcUnlinkSlots(src=frame[1], dest=frame[2]) 283 if op == OPC_RQ_SL_DATA: 284 return OpcRqSlData(slot=frame[1]) 285 if op == OPC_LOCO_ADR: 286 return OpcLocoAdr(address=(frame[1] << 7) | frame[2]) 287 if op == OPC_LOCO_SPD: 288 return OpcLocoSpd(slot=frame[1], speed=frame[2]) 289 if op == OPC_LOCO_DIRF: 290 return OpcLocoDirF(slot=frame[1], dirf=frame[2]) 291 if op == OPC_LOCO_SND: 292 return OpcLocoSnd(slot=frame[1], snd=frame[2]) 293 294 # Variable-length: 14-byte slot data frames. 295 if op == OPC_SL_RD_DATA and len(frame) == 14: 296 return OpcSlRdData( 297 slot=frame[2], stat=frame[3], 298 address=(frame[9] << 7) | frame[4], 299 speed=frame[5], dirf=frame[6], trk=frame[7], 300 ss2=frame[8], snd=frame[10], 301 ) 302 if op == OPC_WR_SL_DATA and len(frame) == 14: 303 return OpcWrSlData( 304 slot=frame[2], stat=frame[3], 305 address=(frame[9] << 7) | frame[4], 306 speed=frame[5], dirf=frame[6], trk=frame[7], 307 ss2=frame[8], snd=frame[10], 308 ) 309 310 return OpcUnknown(opcode=op, payload=bytes(frame[1:-1])) 311 312 313# ---------- encode ----------------------------------------------------------- 314 315 316def encode(msg: LocoNetMessage) -> bytes: 317 """Turn *msg* into a complete frame (opcode + payload + checksum).""" 318 op = msg.opcode 319 320 # 2-byte 321 if op in (OPC_GPON, OPC_GPOFF, OPC_IDLE): 322 return append_checksum(bytes([op])) 323 324 # 4-byte — isinstance narrows the type cleanly per branch. 325 if isinstance(msg, OpcSwReq): 326 b1, b2 = _encode_switch_address( 327 msg.address, closed=msg.closed, output_on=msg.output_on 328 ) 329 return append_checksum(bytes([op, b1, b2])) 330 if isinstance(msg, OpcSwRep): 331 b1, b2 = _encode_switch_address( 332 msg.address, closed=msg.closed, output_on=False, report_mode=True 333 ) 334 return append_checksum(bytes([op, b1, b2])) 335 if isinstance(msg, OpcInputRep): 336 addr = msg.address - 1 337 sensor_bit = addr & 0x01 338 addr_words = addr >> 1 339 b1 = addr_words & 0x7F 340 b2 = ((addr_words >> 7) & 0x0F) | (sensor_bit << 5) | (0x10 if msg.active else 0) 341 return append_checksum(bytes([op, b1, b2])) 342 if isinstance(msg, OpcLongAck): 343 return append_checksum( 344 bytes([op, msg.responding_opcode & 0x7F, msg.ack_code & 0x7F]) 345 ) 346 if isinstance(msg, OpcSlotStat1): 347 return append_checksum(bytes([op, msg.slot & 0x7F, msg.stat & 0x7F])) 348 if isinstance(msg, OpcMoveSlots): 349 return append_checksum(bytes([op, msg.src & 0x7F, msg.dest & 0x7F])) 350 if isinstance(msg, OpcLinkSlots): 351 return append_checksum(bytes([op, msg.src & 0x7F, msg.dest & 0x7F])) 352 if isinstance(msg, OpcUnlinkSlots): 353 return append_checksum(bytes([op, msg.src & 0x7F, msg.dest & 0x7F])) 354 if isinstance(msg, OpcRqSlData): 355 return append_checksum(bytes([op, msg.slot & 0x7F, 0x00])) 356 if isinstance(msg, OpcLocoAdr): 357 return append_checksum( 358 bytes([op, (msg.address >> 7) & 0x7F, msg.address & 0x7F]) 359 ) 360 if isinstance(msg, OpcLocoSpd): 361 return append_checksum(bytes([op, msg.slot & 0x7F, msg.speed & 0x7F])) 362 if isinstance(msg, OpcLocoDirF): 363 return append_checksum(bytes([op, msg.slot & 0x7F, msg.dirf & 0x7F])) 364 if isinstance(msg, OpcLocoSnd): 365 return append_checksum(bytes([op, msg.slot & 0x7F, msg.snd & 0x7F])) 366 367 # 14-byte slot data frames. 368 if isinstance(msg, (OpcSlRdData, OpcWrSlData)): 369 body = bytes([ 370 op, 371 14, # length byte 372 msg.slot & 0x7F, 373 msg.stat & 0x7F, 374 msg.address & 0x7F, # ADR (low) 375 msg.speed & 0x7F, 376 msg.dirf & 0x7F, 377 msg.trk & 0x7F, 378 msg.ss2 & 0x7F, # SS2 — hours for slot 123, STAT2 elsewhere 379 (msg.address >> 7) & 0x7F, # ADR2 (high) 380 msg.snd & 0x7F, 381 0x00, # ID1 382 0x00, # ID2 383 ]) 384 return append_checksum(body) 385 386 if isinstance(msg, OpcUnknown): 387 body = bytes([msg.opcode]) + msg.payload 388 return append_checksum(body) 389 390 raise NotImplementedError(f"encode not implemented for opcode 0x{op:02X}") 391 392 393# ---------- internals -------------------------------------------------------- 394 395 396def _decode_switch_address(b1: int, b2: int) -> int: 397 """Decode a LocoNet switch (turnout) address from the two body bytes.""" 398 # b1 = lower 7 bits, b2 low 4 bits = upper 4 bits; address is 1-based. 399 return ((b2 & 0x0F) << 7 | (b1 & 0x7F)) + 1 400 401 402def _encode_switch_address( 403 address: int, *, closed: bool, output_on: bool, report_mode: bool = False 404) -> tuple[int, int]: 405 """Encode a switch address into the two body bytes. 406 407 *report_mode* is True for OPC_SW_REP frames (bit-3 layout differs slightly 408 in JMRI; the visible difference is which bits carry feedback flags). 409 """ 410 addr0 = address - 1 411 b1 = addr0 & 0x7F 412 b2 = (addr0 >> 7) & 0x0F 413 if closed: 414 b2 |= 0x20 415 if output_on and not report_mode: 416 b2 |= 0x10 417 return b1, b2 418 419 420# Re-export so the test suite can compute its own checksums. 421__all__ = [ 422 "LocoNetMessage", 423 "OpcGpOff", 424 "OpcGpOn", 425 "OpcIdle", 426 "OpcInputRep", 427 "OpcLinkSlots", 428 "OpcLocoAdr", 429 "OpcLocoDirF", 430 "OpcLocoSnd", 431 "OpcLocoSpd", 432 "OpcLongAck", 433 "OpcMoveSlots", 434 "OpcUnlinkSlots", 435 "OpcRqSlData", 436 "OpcSlRdData", 437 "OpcSlotStat1", 438 "OpcSwRep", 439 "OpcSwReq", 440 "OpcUnknown", 441 "OpcWrSlData", 442 "compute_checksum", 443 "decode", 444 "encode", 445] 446 447# Re-export used internally so static analyzers don't flag the import. 448_ = compute_checksum
40class LocoNetMessage: 41 """Marker base type. Subclasses are frozen dataclasses.""" 42 opcode: int
Marker base type. Subclasses are frozen dataclasses.
95@dataclass(frozen=True, slots=True) 96class OpcInputRep(LocoNetMessage): 97 """Sensor input report.""" 98 address: int 99 active: bool 100 opcode: int = OPC_INPUT_REP
Sensor input report.
126@dataclass(frozen=True, slots=True) 127class OpcLinkSlots(LocoNetMessage): 128 """Link ``src`` as a follower of ``dest`` — build a consist chain. 129 130 After a LINK, the command station broadcasts speed and direction 131 commands from *dest* to *src* automatically; the client only needs 132 to drive the lead loco. Functions remain per-loco. 133 """ 134 src: int 135 dest: int 136 opcode: int = OPC_LINK_SLOTS
159@dataclass(frozen=True, slots=True) 160class OpcLocoAdr(LocoNetMessage): 161 """Request the slot currently holding *address*. 162 163 Long addresses use the high byte; short addresses set high=0. 164 """ 165 address: int 166 opcode: int = OPC_LOCO_ADR
Request the slot currently holding address.
Long addresses use the high byte; short addresses set high=0.
177@dataclass(frozen=True, slots=True) 178class OpcLocoDirF(LocoNetMessage): 179 """Set direction + F0..F4 for a slot. 180 181 ``dirf`` is the wire byte: bit 5 = direction (0 = forward), bits 0..4 = F0..F4. 182 """ 183 slot: int 184 dirf: int 185 opcode: int = OPC_LOCO_DIRF
Set direction + F0..F4 for a slot.
dirf is the wire byte: bit 5 = direction (0 = forward), bits 0..4 = F0..F4.
188@dataclass(frozen=True, slots=True) 189class OpcLocoSnd(LocoNetMessage): 190 """Set F5..F8 for a slot. ``snd`` packs F5..F8 in bits 0..3.""" 191 slot: int 192 snd: int 193 opcode: int = OPC_LOCO_SND
Set F5..F8 for a slot. snd packs F5..F8 in bits 0..3.
169@dataclass(frozen=True, slots=True) 170class OpcLocoSpd(LocoNetMessage): 171 """Set speed for a slot. ``speed`` is 0..127.""" 172 slot: int 173 speed: int 174 opcode: int = OPC_LOCO_SPD
Set speed for a slot. speed is 0..127.
103@dataclass(frozen=True, slots=True) 104class OpcLongAck(LocoNetMessage): 105 """Long-form acknowledgment — sent in response to many request opcodes.""" 106 responding_opcode: int 107 ack_code: int 108 opcode: int = OPC_LONG_ACK
Long-form acknowledgment — sent in response to many request opcodes.
118@dataclass(frozen=True, slots=True) 119class OpcMoveSlots(LocoNetMessage): 120 """Move slot src → dest. Same src+dest=null move (refresh); 0=dispatch.""" 121 src: int 122 dest: int 123 opcode: int = OPC_MOVE_SLOTS
Move slot src → dest. Same src+dest=null move (refresh); 0=dispatch.
139@dataclass(frozen=True, slots=True) 140class OpcUnlinkSlots(LocoNetMessage): 141 """Break the LINK between *src* and *dest*. 142 143 Ordering: *dest* is the slot higher in the chain, *src* is the 144 follower being detached. After UNLINK, *src* is a standalone slot 145 again and stops receiving speed/direction fan-out from *dest*. 146 """ 147 src: int 148 dest: int 149 opcode: int = OPC_UNLINK_SLOTS
Break the LINK between src and dest.
Ordering: dest is the slot higher in the chain, src is the follower being detached. After UNLINK, src is a standalone slot again and stops receiving speed/direction fan-out from dest.
152@dataclass(frozen=True, slots=True) 153class OpcRqSlData(LocoNetMessage): 154 """Request slot data — server replies with OpcSlRdData.""" 155 slot: int 156 opcode: int = OPC_RQ_SL_DATA
Request slot data — server replies with OpcSlRdData.
199@dataclass(frozen=True, slots=True) 200class OpcSlRdData(LocoNetMessage): 201 """Slot read data — 14-byte response carrying full slot state. 202 203 ``ss2`` (byte index 8 on the wire) holds the "STAT2" byte for 204 regular slots, and the encoded hours field for the fast-clock 205 slot (123). It defaults to 0 so existing callers don't need to 206 care; the clock module fills it in. 207 """ 208 slot: int 209 stat: int 210 address: int # composed from ADR + ADR2 211 speed: int 212 dirf: int 213 trk: int 214 snd: int 215 ss2: int = 0 216 opcode: int = OPC_SL_RD_DATA
Slot read data — 14-byte response carrying full slot state.
ss2 (byte index 8 on the wire) holds the "STAT2" byte for
regular slots, and the encoded hours field for the fast-clock
slot (123). It defaults to 0 so existing callers don't need to
care; the clock module fills it in.
86@dataclass(frozen=True, slots=True) 87class OpcSwRep(LocoNetMessage): 88 """Switch report — turnout state feedback from the layout.""" 89 address: int 90 closed: bool 91 thrown: bool 92 opcode: int = OPC_SW_REP
Switch report — turnout state feedback from the layout.
73@dataclass(frozen=True, slots=True) 74class OpcSwReq(LocoNetMessage): 75 """Switch request — throw or close a turnout. 76 77 Address is 1..2048 (LocoNet packs it across two bytes with bit layout 78 that JMRI documents explicitly). 79 """ 80 address: int 81 closed: bool # True = closed direction, False = thrown 82 output_on: bool # True = energize, False = de-energize 83 opcode: int = OPC_SW_REQ
Switch request — throw or close a turnout.
Address is 1..2048 (LocoNet packs it across two bytes with bit layout that JMRI documents explicitly).
45@dataclass(frozen=True, slots=True) 46class OpcUnknown(LocoNetMessage): 47 """A well-formed frame whose opcode we don't have a typed wrapper for.""" 48 opcode: int 49 payload: bytes # everything between opcode and checksum (exclusive)
A well-formed frame whose opcode we don't have a typed wrapper for.
219@dataclass(frozen=True, slots=True) 220class OpcWrSlData(LocoNetMessage): 221 """Write slot data — same shape as OpcSlRdData but client → master.""" 222 slot: int 223 stat: int 224 address: int 225 speed: int 226 dirf: int 227 trk: int 228 snd: int 229 ss2: int = 0 230 opcode: int = OPC_WR_SL_DATA
Write slot data — same shape as OpcSlRdData but client → master.
20def compute_checksum(body: bytes) -> int: 21 """Return the LocoNet checksum byte for *body* (everything before the CS). 22 23 The checksum is ``0xFF XOR`` of every byte in *body*. 24 """ 25 cs = 0xFF 26 for b in body: 27 cs ^= b 28 return cs
Return the LocoNet checksum byte for body (everything before the CS).
The checksum is 0xFF XOR of every byte in body.
236def decode(frame: bytes) -> LocoNetMessage: 237 """Turn a complete, checksum-verified frame into a typed message. 238 239 Raises :class:`ValueError` if the checksum is invalid. 240 """ 241 if not verify_checksum(frame): 242 raise ValueError("LocoNet frame has invalid checksum") 243 op = frame[0] 244 245 # 2-byte 246 if op == OPC_GPON: 247 return OpcGpOn() 248 if op == OPC_GPOFF: 249 return OpcGpOff() 250 if op == OPC_IDLE: 251 return OpcIdle() 252 253 # 4-byte: layout is [op, b1, b2, cs]; b1/b2 vary per opcode. 254 if op == OPC_SW_REQ: 255 return OpcSwReq( 256 address=_decode_switch_address(frame[1], frame[2]), 257 closed=bool(frame[2] & 0x20), 258 output_on=bool(frame[2] & 0x10), 259 ) 260 if op == OPC_SW_REP: 261 return OpcSwRep( 262 address=_decode_switch_address(frame[1], frame[2]), 263 closed=bool(frame[2] & 0x20), 264 thrown=not bool(frame[2] & 0x20), 265 ) 266 if op == OPC_INPUT_REP: 267 # JMRI's mapping: 268 # pair = ((SN2 & 0x0F) << 7) | (SN1 & 0x7F) 269 # sensor (1-based) = pair * 2 + ((SN2 & 0x20) >> 5) + 1 270 pair = ((frame[2] & 0x0F) << 7) | (frame[1] & 0x7F) 271 sensor_addr = (pair * 2) + ((frame[2] & 0x20) >> 5) + 1 272 active = bool(frame[2] & 0x10) 273 return OpcInputRep(address=sensor_addr, active=active) 274 if op == OPC_LONG_ACK: 275 return OpcLongAck(responding_opcode=frame[1] | 0x80, ack_code=frame[2]) 276 if op == OPC_SLOT_STAT1: 277 return OpcSlotStat1(slot=frame[1], stat=frame[2]) 278 if op == OPC_MOVE_SLOTS: 279 return OpcMoveSlots(src=frame[1], dest=frame[2]) 280 if op == OPC_LINK_SLOTS: 281 return OpcLinkSlots(src=frame[1], dest=frame[2]) 282 if op == OPC_UNLINK_SLOTS: 283 return OpcUnlinkSlots(src=frame[1], dest=frame[2]) 284 if op == OPC_RQ_SL_DATA: 285 return OpcRqSlData(slot=frame[1]) 286 if op == OPC_LOCO_ADR: 287 return OpcLocoAdr(address=(frame[1] << 7) | frame[2]) 288 if op == OPC_LOCO_SPD: 289 return OpcLocoSpd(slot=frame[1], speed=frame[2]) 290 if op == OPC_LOCO_DIRF: 291 return OpcLocoDirF(slot=frame[1], dirf=frame[2]) 292 if op == OPC_LOCO_SND: 293 return OpcLocoSnd(slot=frame[1], snd=frame[2]) 294 295 # Variable-length: 14-byte slot data frames. 296 if op == OPC_SL_RD_DATA and len(frame) == 14: 297 return OpcSlRdData( 298 slot=frame[2], stat=frame[3], 299 address=(frame[9] << 7) | frame[4], 300 speed=frame[5], dirf=frame[6], trk=frame[7], 301 ss2=frame[8], snd=frame[10], 302 ) 303 if op == OPC_WR_SL_DATA and len(frame) == 14: 304 return OpcWrSlData( 305 slot=frame[2], stat=frame[3], 306 address=(frame[9] << 7) | frame[4], 307 speed=frame[5], dirf=frame[6], trk=frame[7], 308 ss2=frame[8], snd=frame[10], 309 ) 310 311 return OpcUnknown(opcode=op, payload=bytes(frame[1:-1]))
Turn a complete, checksum-verified frame into a typed message.
Raises ValueError if the checksum is invalid.
317def encode(msg: LocoNetMessage) -> bytes: 318 """Turn *msg* into a complete frame (opcode + payload + checksum).""" 319 op = msg.opcode 320 321 # 2-byte 322 if op in (OPC_GPON, OPC_GPOFF, OPC_IDLE): 323 return append_checksum(bytes([op])) 324 325 # 4-byte — isinstance narrows the type cleanly per branch. 326 if isinstance(msg, OpcSwReq): 327 b1, b2 = _encode_switch_address( 328 msg.address, closed=msg.closed, output_on=msg.output_on 329 ) 330 return append_checksum(bytes([op, b1, b2])) 331 if isinstance(msg, OpcSwRep): 332 b1, b2 = _encode_switch_address( 333 msg.address, closed=msg.closed, output_on=False, report_mode=True 334 ) 335 return append_checksum(bytes([op, b1, b2])) 336 if isinstance(msg, OpcInputRep): 337 addr = msg.address - 1 338 sensor_bit = addr & 0x01 339 addr_words = addr >> 1 340 b1 = addr_words & 0x7F 341 b2 = ((addr_words >> 7) & 0x0F) | (sensor_bit << 5) | (0x10 if msg.active else 0) 342 return append_checksum(bytes([op, b1, b2])) 343 if isinstance(msg, OpcLongAck): 344 return append_checksum( 345 bytes([op, msg.responding_opcode & 0x7F, msg.ack_code & 0x7F]) 346 ) 347 if isinstance(msg, OpcSlotStat1): 348 return append_checksum(bytes([op, msg.slot & 0x7F, msg.stat & 0x7F])) 349 if isinstance(msg, OpcMoveSlots): 350 return append_checksum(bytes([op, msg.src & 0x7F, msg.dest & 0x7F])) 351 if isinstance(msg, OpcLinkSlots): 352 return append_checksum(bytes([op, msg.src & 0x7F, msg.dest & 0x7F])) 353 if isinstance(msg, OpcUnlinkSlots): 354 return append_checksum(bytes([op, msg.src & 0x7F, msg.dest & 0x7F])) 355 if isinstance(msg, OpcRqSlData): 356 return append_checksum(bytes([op, msg.slot & 0x7F, 0x00])) 357 if isinstance(msg, OpcLocoAdr): 358 return append_checksum( 359 bytes([op, (msg.address >> 7) & 0x7F, msg.address & 0x7F]) 360 ) 361 if isinstance(msg, OpcLocoSpd): 362 return append_checksum(bytes([op, msg.slot & 0x7F, msg.speed & 0x7F])) 363 if isinstance(msg, OpcLocoDirF): 364 return append_checksum(bytes([op, msg.slot & 0x7F, msg.dirf & 0x7F])) 365 if isinstance(msg, OpcLocoSnd): 366 return append_checksum(bytes([op, msg.slot & 0x7F, msg.snd & 0x7F])) 367 368 # 14-byte slot data frames. 369 if isinstance(msg, (OpcSlRdData, OpcWrSlData)): 370 body = bytes([ 371 op, 372 14, # length byte 373 msg.slot & 0x7F, 374 msg.stat & 0x7F, 375 msg.address & 0x7F, # ADR (low) 376 msg.speed & 0x7F, 377 msg.dirf & 0x7F, 378 msg.trk & 0x7F, 379 msg.ss2 & 0x7F, # SS2 — hours for slot 123, STAT2 elsewhere 380 (msg.address >> 7) & 0x7F, # ADR2 (high) 381 msg.snd & 0x7F, 382 0x00, # ID1 383 0x00, # ID2 384 ]) 385 return append_checksum(body) 386 387 if isinstance(msg, OpcUnknown): 388 body = bytes([msg.opcode]) + msg.payload 389 return append_checksum(body) 390 391 raise NotImplementedError(f"encode not implemented for opcode 0x{op:02X}")
Turn msg into a complete frame (opcode + payload + checksum).