jmri_loconet
jmri-loconet — Stage 4 of the JMRI Python rewrite.
The first real hardware driver. Subclass of jmri_core.hal.SystemConnection
that drives the LocoNet wire protocol (Digitrax). Registered via
[project.entry-points."jmri.connection"] loconet so the core plugin
discovery picks it up after a pip install.
1"""jmri-loconet — Stage 4 of the JMRI Python rewrite. 2 3The first real hardware driver. Subclass of ``jmri_core.hal.SystemConnection`` 4that drives the LocoNet wire protocol (Digitrax). Registered via 5``[project.entry-points."jmri.connection"] loconet`` so the core plugin 6discovery picks it up after a ``pip install``. 7""" 8from jmri_loconet.beans import ( 9 LnPowerManager, 10 LnSensor, 11 LnSensorManager, 12 LnTurnout, 13 LnTurnoutManager, 14) 15from jmri_loconet.connection import LocoNetConnection 16from jmri_loconet.programmer import ( 17 LnOpsProgrammer, 18 LnProgrammer, 19 LnProgrammerManager, 20 LnServiceProgrammer, 21) 22from jmri_loconet.slots import Slot, SlotManager 23from jmri_loconet.throttle import LnThrottle, LnThrottleManager 24from jmri_loconet.traffic import LocoNetTrafficController 25from jmri_loconet.transport import LocoNetPort, LoopbackPort, SerialPort, TcpPort, loopback_pair 26 27__version__ = "0.1.0" 28 29__all__ = [ 30 "LnOpsProgrammer", 31 "LnPowerManager", 32 "LnProgrammer", 33 "LnProgrammerManager", 34 "LnSensor", 35 "LnSensorManager", 36 "LnServiceProgrammer", 37 "LnThrottle", 38 "LnThrottleManager", 39 "LnTurnout", 40 "LnTurnoutManager", 41 "LocoNetConnection", 42 "LocoNetPort", 43 "LocoNetTrafficController", 44 "LoopbackPort", 45 "SerialPort", 46 "Slot", 47 "SlotManager", 48 "TcpPort", 49 "__version__", 50 "loopback_pair", 51]
256class LnOpsProgrammer(Programmer): 257 """Ops-mode adapter scoped to a specific DCC address.""" 258 259 def __init__(self, inner: LnProgrammer, address: int, *, long_address: bool) -> None: 260 self._inner = inner 261 self._address = address 262 self._long = long_address 263 264 @property 265 def mode(self) -> ProgrammingMode: 266 return ProgrammingMode.OPS_MODE 267 268 @property 269 def address(self) -> int: 270 return self._address 271 272 async def read_cv(self, cv: int) -> int: 273 return await self._inner.read_cv_ops(self._address, cv) 274 275 async def write_cv(self, cv: int, value: int) -> None: 276 await self._inner.write_cv_ops(self._address, cv, value)
Ops-mode adapter scoped to a specific DCC address.
130class LnPowerManager(PowerManager): 131 """LocoNet track power. Sends OPC_GPON/OPC_GPOFF/OPC_IDLE.""" 132 133 def __init__(self, tc: LocoNetTrafficController) -> None: 134 super().__init__() 135 self._tc = tc 136 self._unsub = tc.inbound.connect(self._on_message) 137 138 async def set_power(self, state: PowerState) -> None: 139 if state == PowerState.ON: 140 await self._tc.send(OpcGpOn()) 141 elif state == PowerState.OFF: 142 await self._tc.send(OpcGpOff()) 143 elif state == PowerState.IDLE: 144 await self._tc.send(OpcIdle()) 145 await self._set_state(state) 146 147 async def _on_message(self, msg: LocoNetMessage) -> None: 148 if isinstance(msg, OpcGpOn): 149 await self._set_state(PowerState.ON) 150 elif isinstance(msg, OpcGpOff): 151 await self._set_state(PowerState.OFF) 152 elif isinstance(msg, OpcIdle): 153 await self._set_state(PowerState.IDLE)
LocoNet track power. Sends OPC_GPON/OPC_GPOFF/OPC_IDLE.
138 async def set_power(self, state: PowerState) -> None: 139 if state == PowerState.ON: 140 await self._tc.send(OpcGpOn()) 141 elif state == PowerState.OFF: 142 await self._tc.send(OpcGpOff()) 143 elif state == PowerState.IDLE: 144 await self._tc.send(OpcIdle()) 145 await self._set_state(state)
Command the layout to state. Base impl just updates in-memory.
Inherited Members
139class LnProgrammer: 140 """Service- and ops-mode CV access against a LocoNet command station.""" 141 142 def __init__(self, tc: LocoNetTrafficController) -> None: 143 self._tc = tc 144 # One waiter at a time (LocoNet programming is sequential). 145 self._busy = asyncio.Lock() 146 147 async def write_cv_direct(self, cv: int, value: int, *, timeout: float = 6.0) -> None: 148 """Service-mode-direct CV write. Blocks until OPC_LONG_ACK arrives.""" 149 async with self._busy: 150 await self._send_and_wait_ack( 151 _build_prog_slot_message(PCMD_SERVICE_DIRECT_WRITE, cv, value), 152 timeout=timeout, 153 ) 154 155 async def read_cv_direct(self, cv: int, *, timeout: float = 6.0) -> int: 156 """Service-mode-direct CV read. Returns the value byte.""" 157 async with self._busy: 158 return await self._send_and_wait_read( 159 _build_prog_slot_message(PCMD_SERVICE_DIRECT_READ, cv, 0), 160 timeout=timeout, 161 ) 162 163 async def write_cv_ops( 164 self, address: int, cv: int, value: int, *, timeout: float = 2.0, 165 ) -> None: 166 """Ops-mode CV write to *address*. No reply needed; we fire-and-forget.""" 167 async with self._busy: 168 await self._tc.send(_build_prog_slot_message( 169 PCMD_OPS_MODE_WRITE, cv, value, ops_address=address, 170 )) 171 172 async def read_cv_ops( 173 self, address: int, cv: int, *, timeout: float = 6.0, 174 ) -> int: 175 """Ops-mode CV read — requires RailCom-capable decoder + readers.""" 176 async with self._busy: 177 return await self._send_and_wait_read( 178 _build_prog_slot_message( 179 PCMD_OPS_MODE_READ, cv, 0, ops_address=address, 180 ), 181 timeout=timeout, 182 ) 183 184 # ----- internals ------------------------------------------------------- 185 186 async def _send_and_wait_ack(self, msg: OpcWrSlData, *, timeout: float) -> None: 187 future: asyncio.Future[None] = asyncio.get_event_loop().create_future() 188 189 async def listener(inbound: LocoNetMessage) -> None: 190 if future.done(): 191 return 192 if isinstance(inbound, OpcLongAck) and inbound.responding_opcode == OPC_WR_SL_DATA: 193 if inbound.ack_code == LONG_ACK_WRITE_DONE: 194 future.set_result(None) 195 else: 196 future.set_exception( 197 RuntimeError(f"LocoNet programming write failed: ack={inbound.ack_code:#x}") 198 ) 199 200 disconnect = self._tc.inbound.connect(listener) 201 try: 202 await self._tc.send(msg) 203 await asyncio.wait_for(future, timeout=timeout) 204 finally: 205 disconnect() 206 207 async def _send_and_wait_read(self, msg: OpcWrSlData, *, timeout: float) -> int: 208 future: asyncio.Future[int] = asyncio.get_event_loop().create_future() 209 210 async def listener(inbound: LocoNetMessage) -> None: 211 if future.done(): 212 return 213 if isinstance(inbound, OpcSlRdData) and inbound.slot == PROGRAMMING_SLOT: 214 # The slot-rd frame carries (CVH, DATA7) at the same byte 215 # positions our OpcSlRdData decoded as (address_high, snd). 216 cvh = (inbound.address >> 7) & 0x7F 217 value = _unpack_data(cvh, inbound.snd) 218 future.set_result(value) 219 elif ( 220 isinstance(inbound, OpcLongAck) 221 and inbound.responding_opcode == OPC_WR_SL_DATA 222 and inbound.ack_code == LONG_ACK_READ_FAIL 223 ): 224 future.set_exception( 225 RuntimeError("LocoNet programming read failed (decoder did not respond)") 226 ) 227 228 disconnect = self._tc.inbound.connect(listener) 229 try: 230 await self._tc.send(msg) 231 return await asyncio.wait_for(future, timeout=timeout) 232 finally: 233 disconnect()
Service- and ops-mode CV access against a LocoNet command station.
147 async def write_cv_direct(self, cv: int, value: int, *, timeout: float = 6.0) -> None: 148 """Service-mode-direct CV write. Blocks until OPC_LONG_ACK arrives.""" 149 async with self._busy: 150 await self._send_and_wait_ack( 151 _build_prog_slot_message(PCMD_SERVICE_DIRECT_WRITE, cv, value), 152 timeout=timeout, 153 )
Service-mode-direct CV write. Blocks until OPC_LONG_ACK arrives.
155 async def read_cv_direct(self, cv: int, *, timeout: float = 6.0) -> int: 156 """Service-mode-direct CV read. Returns the value byte.""" 157 async with self._busy: 158 return await self._send_and_wait_read( 159 _build_prog_slot_message(PCMD_SERVICE_DIRECT_READ, cv, 0), 160 timeout=timeout, 161 )
Service-mode-direct CV read. Returns the value byte.
163 async def write_cv_ops( 164 self, address: int, cv: int, value: int, *, timeout: float = 2.0, 165 ) -> None: 166 """Ops-mode CV write to *address*. No reply needed; we fire-and-forget.""" 167 async with self._busy: 168 await self._tc.send(_build_prog_slot_message( 169 PCMD_OPS_MODE_WRITE, cv, value, ops_address=address, 170 ))
Ops-mode CV write to address. No reply needed; we fire-and-forget.
172 async def read_cv_ops( 173 self, address: int, cv: int, *, timeout: float = 6.0, 174 ) -> int: 175 """Ops-mode CV read — requires RailCom-capable decoder + readers.""" 176 async with self._busy: 177 return await self._send_and_wait_read( 178 _build_prog_slot_message( 179 PCMD_OPS_MODE_READ, cv, 0, ops_address=address, 180 ), 181 timeout=timeout, 182 )
Ops-mode CV read — requires RailCom-capable decoder + readers.
279class LnProgrammerManager(ProgrammerManager): 280 """Hands out LocoNet service/ops programmers backed by one :class:`LnProgrammer`.""" 281 282 def __init__(self, inner: LnProgrammer) -> None: 283 self._inner = inner 284 self._service_singleton: LnServiceProgrammer | None = None 285 286 def global_programmer(self) -> LnServiceProgrammer: 287 if self._service_singleton is None: 288 self._service_singleton = LnServiceProgrammer(self._inner) 289 return self._service_singleton 290 291 def ops_programmer(self, address: int, *, long_address: bool = False) -> LnOpsProgrammer: 292 return LnOpsProgrammer(self._inner, address, long_address=long_address) 293 294 @property 295 def inner(self) -> LnProgrammer: 296 return self._inner
Hands out LocoNet service/ops programmers backed by one LnProgrammer.
88class LnSensor(Sensor): 89 """LocoNet-aware sensor — read-only from the layout.""" 90 91 __slots__ = ("_address",) 92 93 def __init__(self, system_name: str, address: int) -> None: 94 super().__init__(system_name) 95 self._address = address 96 97 @property 98 def address(self) -> int: 99 return self._address
LocoNet-aware sensor — read-only from the layout.
102class LnSensorManager(Manager[LnSensor]): 103 """Manager that constructs LocoNet sensors and listens for OPC_INPUT_REP.""" 104 105 type_letter = "S" 106 107 def __init__(self, system_prefix: str, *, tc: LocoNetTrafficController) -> None: 108 super().__init__(system_prefix) 109 self._tc = tc 110 self._by_address: dict[int, LnSensor] = {} 111 self._unsub = tc.inbound.connect(self._on_message) 112 113 def _create(self, system_name: str) -> LnSensor: 114 address = _parse_address(system_name, self.full_prefix) 115 bean = LnSensor(system_name, address) 116 self._by_address[address] = bean 117 return bean 118 119 async def _on_message(self, msg: LocoNetMessage) -> None: 120 if not isinstance(msg, OpcInputRep): 121 return 122 bean = self._by_address.get(msg.address) 123 if bean is None: 124 # Sensors aren't always pre-created; clients can opt in via provide(). 125 return 126 state = SensorState.ACTIVE if msg.active else SensorState.INACTIVE 127 await bean._set_known_state(state)
Manager that constructs LocoNet sensors and listens for OPC_INPUT_REP.
239class LnServiceProgrammer(Programmer): 240 """Service-mode-direct adapter satisfying the core ABC.""" 241 242 def __init__(self, inner: LnProgrammer) -> None: 243 self._inner = inner 244 245 @property 246 def mode(self) -> ProgrammingMode: 247 return ProgrammingMode.SERVICE_DIRECT 248 249 async def read_cv(self, cv: int) -> int: 250 return await self._inner.read_cv_direct(cv) 251 252 async def write_cv(self, cv: int, value: int) -> None: 253 await self._inner.write_cv_direct(cv, value)
Service-mode-direct adapter satisfying the core ABC.
88class LnThrottle(Throttle): 89 """A LocoNet throttle bound to one acquired slot.""" 90 91 def __init__(self, address: int, slot: Slot, tc: LocoNetTrafficController, 92 long_address: bool = False) -> None: 93 super().__init__(address=address, long_address=long_address) 94 self._slot = slot 95 self._tc = tc 96 97 @property 98 def slot(self) -> Slot: 99 return self._slot 100 101 async def set_speed(self, speed: float) -> None: 102 await super().set_speed(speed) 103 await self._tc.send(OpcLocoSpd(slot=self._slot.number, speed=speed_to_wire(speed))) 104 105 async def set_direction(self, direction: ThrottleDirection) -> None: 106 await super().set_direction(direction) 107 await self._push_dirf() 108 109 async def set_function(self, index: int, on: bool) -> None: 110 await super().set_function(index, on) 111 if index <= 4: 112 await self._push_dirf() 113 elif index <= 8: 114 await self._tc.send(OpcLocoSnd(slot=self._slot.number, snd=pack_snd(self.functions))) 115 elif index <= 20: 116 await self._push_func_group(group=3) 117 elif index <= 28: 118 await self._push_func_group(group=4) 119 120 async def _push_func_group(self, *, group: int) -> None: 121 """Build an NMRA function-high DCC packet and ship it via OPC_IMM_PACKET.""" 122 dcc = function_high_packet_dcc( 123 address=self.address, 124 long_address=self.long_address, 125 functions=self.functions, 126 group=group, 127 ) 128 body = wrap_in_imm_packet(dcc, repeat=2) 129 # Send as a raw OpcUnknown with the IMM_PACKET opcode — the 130 # traffic controller will compute and append the checksum. 131 # The first byte of body is already the opcode; strip it so 132 # OpcUnknown's payload contains just the rest. 133 assert body[0] == OPC_IMM_PACKET 134 await self._tc.send(OpcUnknown(opcode=OPC_IMM_PACKET, payload=body[1:])) 135 136 async def _push_dirf(self) -> None: 137 await self._tc.send(OpcLocoDirF( 138 slot=self._slot.number, 139 dirf=pack_dirf(self.direction, self.functions), 140 ))
A LocoNet throttle bound to one acquired slot.
109 async def set_function(self, index: int, on: bool) -> None: 110 await super().set_function(index, on) 111 if index <= 4: 112 await self._push_dirf() 113 elif index <= 8: 114 await self._tc.send(OpcLocoSnd(slot=self._slot.number, snd=pack_snd(self.functions))) 115 elif index <= 20: 116 await self._push_func_group(group=3) 117 elif index <= 28: 118 await self._push_func_group(group=4)
143class LnThrottleManager(ThrottleManager): 144 """LocoNet throttle manager — extends the core ThrottleManager. 145 146 Override hook :meth:`_construct` acquires a slot via :class:`SlotManager` 147 before handing the throttle back. :meth:`release` is overridden to 148 return the slot to the command station as well as drop the bookkeeping. 149 150 Owns the slot-refresh task: periodically sends a null-move 151 (``OPC_MOVE_SLOTS src=N dest=N``) on every active slot so the CS 152 doesn't purge it during long operations. 153 """ 154 155 def __init__( 156 self, 157 system_prefix: str, 158 *, 159 tc: LocoNetTrafficController, 160 slot_manager: SlotManager, 161 refresh_interval: float = SLOT_REFRESH_INTERVAL_S, 162 ) -> None: 163 super().__init__(system_prefix) 164 self._tc = tc 165 self._slots = slot_manager 166 self._refresh_interval = refresh_interval 167 self._refresh_task: asyncio.Task[None] | None = None 168 self._refresh_stop = asyncio.Event() 169 170 async def _construct(self, address: int, *, long_address: bool) -> LnThrottle: 171 slot = await self._slots.acquire(address) 172 await self._slots.claim(slot) 173 return LnThrottle( 174 address=address, slot=slot, tc=self._tc, long_address=long_address 175 ) 176 177 async def release(self, throttle: Throttle) -> None: 178 if isinstance(throttle, LnThrottle): 179 await self._slots.release(throttle.slot) 180 await super().release(throttle) 181 182 # ----- slot refresh task ------------------------------------------------ 183 184 def start_refresh(self) -> None: 185 """Start the periodic slot-refresh background task. Idempotent.""" 186 if self._refresh_task is not None: 187 return 188 self._refresh_stop.clear() 189 self._refresh_task = asyncio.create_task( 190 self._refresh_loop(), name="loconet-slot-refresh", 191 ) 192 193 async def stop_refresh(self) -> None: 194 """Stop the slot-refresh task. Idempotent.""" 195 if self._refresh_task is None: 196 return 197 self._refresh_stop.set() 198 self._refresh_task.cancel() 199 import contextlib # noqa: PLC0415 200 with contextlib.suppress(asyncio.CancelledError, Exception): 201 await self._refresh_task 202 self._refresh_task = None 203 204 async def _refresh_loop(self) -> None: 205 import contextlib # noqa: PLC0415 206 try: 207 while not self._refresh_stop.is_set(): 208 with contextlib.suppress(TimeoutError): 209 await asyncio.wait_for( 210 self._refresh_stop.wait(), 211 timeout=self._refresh_interval, 212 ) 213 if self._refresh_stop.is_set(): 214 return 215 await self._refresh_all_slots() 216 except asyncio.CancelledError: 217 return 218 219 async def _refresh_all_slots(self) -> None: 220 """Send a null-move for every currently-active throttle's slot.""" 221 for throttle in list(self): 222 if not isinstance(throttle, LnThrottle): 223 continue 224 try: 225 await self._tc.send(OpcMoveSlots( 226 src=throttle.slot.number, dest=throttle.slot.number, 227 )) 228 except Exception as e: # noqa: BLE001 229 _log.warning("slot refresh failed for slot %d: %s", throttle.slot.number, e)
LocoNet throttle manager — extends the core ThrottleManager.
Override hook _construct() acquires a slot via SlotManager
before handing the throttle back. release() is overridden to
return the slot to the command station as well as drop the bookkeeping.
Owns the slot-refresh task: periodically sends a null-move
(OPC_MOVE_SLOTS src=N dest=N) on every active slot so the CS
doesn't purge it during long operations.
155 def __init__( 156 self, 157 system_prefix: str, 158 *, 159 tc: LocoNetTrafficController, 160 slot_manager: SlotManager, 161 refresh_interval: float = SLOT_REFRESH_INTERVAL_S, 162 ) -> None: 163 super().__init__(system_prefix) 164 self._tc = tc 165 self._slots = slot_manager 166 self._refresh_interval = refresh_interval 167 self._refresh_task: asyncio.Task[None] | None = None 168 self._refresh_stop = asyncio.Event()
184 def start_refresh(self) -> None: 185 """Start the periodic slot-refresh background task. Idempotent.""" 186 if self._refresh_task is not None: 187 return 188 self._refresh_stop.clear() 189 self._refresh_task = asyncio.create_task( 190 self._refresh_loop(), name="loconet-slot-refresh", 191 )
Start the periodic slot-refresh background task. Idempotent.
193 async def stop_refresh(self) -> None: 194 """Stop the slot-refresh task. Idempotent.""" 195 if self._refresh_task is None: 196 return 197 self._refresh_stop.set() 198 self._refresh_task.cancel() 199 import contextlib # noqa: PLC0415 200 with contextlib.suppress(asyncio.CancelledError, Exception): 201 await self._refresh_task 202 self._refresh_task = None
Stop the slot-refresh task. Idempotent.
Inherited Members
30class LnTurnout(Turnout): 31 """LocoNet-aware turnout: sends OPC_SW_REQ on commanded-state change.""" 32 33 __slots__ = ("_address", "_tc") 34 35 def __init__(self, system_name: str, address: int, tc: LocoNetTrafficController) -> None: 36 super().__init__(system_name) 37 self._address = address 38 self._tc = tc 39 40 @property 41 def address(self) -> int: 42 return self._address 43 44 async def set_commanded_state(self, state: TurnoutState) -> None: 45 if state in (TurnoutState.CLOSED, TurnoutState.THROWN): 46 await self._tc.send(OpcSwReq( 47 address=self._address, 48 closed=(state == TurnoutState.CLOSED), 49 output_on=True, 50 )) 51 # Update the in-memory state via the base implementation; for 52 # DIRECT-feedback turnouts known_state will mirror this until/unless 53 # a layout report overrides it. 54 await super().set_commanded_state(state)
LocoNet-aware turnout: sends OPC_SW_REQ on commanded-state change.
44 async def set_commanded_state(self, state: TurnoutState) -> None: 45 if state in (TurnoutState.CLOSED, TurnoutState.THROWN): 46 await self._tc.send(OpcSwReq( 47 address=self._address, 48 closed=(state == TurnoutState.CLOSED), 49 output_on=True, 50 )) 51 # Update the in-memory state via the base implementation; for 52 # DIRECT-feedback turnouts known_state will mirror this until/unless 53 # a layout report overrides it. 54 await super().set_commanded_state(state)
Ask the layout to throw or close the switch.
The base implementation only updates the in-memory commanded state
and emits the signal. Driver subclasses override this to push a
hardware message and then call super().set_commanded_state().
57class LnTurnoutManager(Manager[LnTurnout]): 58 """Manager that constructs LocoNet turnouts and tracks them by address.""" 59 60 type_letter = "T" 61 62 def __init__(self, system_prefix: str, *, tc: LocoNetTrafficController) -> None: 63 super().__init__(system_prefix) 64 self._tc = tc 65 self._by_address: dict[int, LnTurnout] = {} 66 self._unsub = tc.inbound.connect(self._on_message) 67 68 def _create(self, system_name: str) -> LnTurnout: 69 address = _parse_address(system_name, self.full_prefix) 70 bean = LnTurnout(system_name, address, self._tc) 71 self._by_address[address] = bean 72 return bean 73 74 async def deregister(self, bean: LnTurnout) -> None: 75 self._by_address.pop(bean.address, None) 76 await super().deregister(bean) 77 78 async def _on_message(self, msg: LocoNetMessage) -> None: 79 if not isinstance(msg, OpcSwRep): 80 return 81 bean = self._by_address.get(msg.address) 82 if bean is None: 83 return 84 state = TurnoutState.CLOSED if msg.closed else TurnoutState.THROWN 85 await bean._set_known_state(state)
Manager that constructs LocoNet turnouts and tracks them by address.
53class LocoNetConnection(SystemConnection): 54 """LocoNet hardware connection. Prefix ``L``, name ``LocoNet``.""" 55 56 name: ClassVar[str] = "LocoNet" 57 prefix: ClassVar[str] = "L" 58 59 def __init__(self) -> None: 60 super().__init__() 61 # These are filled in by open(). The base-class defaults stay in place 62 # until then so the connection has well-formed managers if opened with 63 # no settings (rare; mainly the test path). 64 self._port: LocoNetPort | None = None 65 self._tc: LocoNetTrafficController | None = None 66 self._slots: SlotManager | None = None 67 self._programmer: LnProgrammer | None = None 68 self._cs_monitor_task: asyncio.Task[None] | None = None 69 70 # ----- lifecycle -------------------------------------------------------- 71 72 async def open(self, settings: ConnectionSettings | None = None) -> None: 73 s = settings or {} 74 self._port = await _build_port(s) 75 self._tc = LocoNetTrafficController(self._port) 76 await self._tc.start() 77 self._slots = SlotManager(self._tc) 78 79 # Replace the base default managers with our LocoNet-aware ones. 80 # ``LnTurnoutManager`` and friends are subclasses of the base managers, 81 # but mypy's invariance in attribute typing means we have to ignore 82 # the assignment — the runtime substitution is sound (Liskov). 83 self.turnouts = LnTurnoutManager(self.prefix, tc=self._tc) # type: ignore[assignment] 84 self.sensors = LnSensorManager(self.prefix, tc=self._tc) # type: ignore[assignment] 85 throttle_mgr = LnThrottleManager( 86 self.prefix, tc=self._tc, slot_manager=self._slots, 87 ) 88 self.throttles = throttle_mgr 89 # Rebind the consist manager onto the LocoNet-aware throttle 90 # manager. Without this, consist fan-out drives placeholder 91 # Throttle beans left over from base ``__init__``, and no 92 # OPC_LOCO_SPD frames reach the wire. 93 self.consists.rebind_throttles(self.throttles) 94 throttle_mgr.start_refresh() 95 self.consists = LnConsistManager( # type: ignore[assignment] 96 throttle_mgr, self.prefix, tc=self._tc, slots=self._slots, 97 ) 98 self.power = LnPowerManager(self._tc) 99 self._programmer = LnProgrammer(self._tc) 100 self.programmers = LnProgrammerManager(self._programmer) 101 # Wire the advanced-consist CV19 write path to the LocoNet 102 # programmer manager. Without this rebind, ``advanced`` consists 103 # go through the base placeholder manager and CV19 never 104 # actually programs. 105 self.consists.rebind_programmers(self.programmers) 106 107 # CS reachability monitor. LocoNet CS emit OPC_IDLE and other 108 # frames continuously; 3s of complete silence is a strong 109 # signal the bus is dark (CS off or unplugged). Start 110 # pessimistic (base class default is optimistic True) so a 111 # home-page render during the monitor's startup grace shows 112 # "Off" until we've actually seen a frame, not a false "On". 113 self._cs_reachable = False 114 self._cs_monitor_task = asyncio.create_task( 115 self._cs_reachability_loop(), name="loconet-cs-reachability", 116 ) 117 118 await super().open(settings) 119 120 async def _cs_reachability_loop(self) -> None: 121 """Flip ``_cs_reachable`` on the 3-second inbound-silence rule. 122 123 LocoNet's continuous chatter (CS heartbeat + slot poll traffic) 124 means any real silence past 3 seconds indicates the CS has 125 stopped talking. Any fresh inbound frame flips reachable back 126 to True immediately. 127 """ 128 import contextlib # noqa: PLC0415 129 try: 130 # 3-second grace so the first CS heartbeat can arrive. 131 await asyncio.sleep(3.0) 132 while True: 133 await asyncio.sleep(1.0) 134 if self._tc is None: 135 return 136 last = self._tc.last_inbound_at 137 if last is None: 138 await self._set_cs_reachable(False) 139 continue 140 age = time.monotonic() - last 141 await self._set_cs_reachable(age <= 3.0) 142 except asyncio.CancelledError: 143 with contextlib.suppress(Exception): 144 return 145 146 async def close(self) -> None: 147 if isinstance(self.throttles, LnThrottleManager): 148 await self.throttles.stop_refresh() 149 if self._cs_monitor_task is not None: 150 self._cs_monitor_task.cancel() 151 import contextlib # noqa: PLC0415 152 with contextlib.suppress(asyncio.CancelledError, Exception): 153 await self._cs_monitor_task 154 self._cs_monitor_task = None 155 if self._tc is not None: 156 await self._tc.aclose() 157 self._tc = None 158 self._port = None 159 await super().close() 160 161 # ----- accessors -------------------------------------------------------- 162 163 @property 164 def slots(self) -> SlotManager: 165 if self._slots is None: 166 raise RuntimeError("connection not open") 167 return self._slots 168 169 @property 170 def traffic(self) -> LocoNetTrafficController: 171 if self._tc is None: 172 raise RuntimeError("connection not open") 173 return self._tc 174 175 # ----- fast clock (slot 123) -------------------------------------------- 176 177 async def read_hardware_clock(self): # noqa: ANN201 178 """Read the CS fast clock from slot 123. 179 180 Sends ``OPC_RQ_SL_DATA(slot=123)`` and awaits the paired 181 ``OPC_SL_RD_DATA`` reply on the inbound signal. Returns a 182 :class:`~jmri_core.beans.fast_clock.HardwareClockState`. 183 184 The :class:`~jmri_server.services.fast_clock.TimeService` 185 prefers this method over the software bean whenever the 186 connection exposes it, so wiring this on ``LocoNetConnection`` 187 automatically makes ``/json/time`` reflect the CS clock. 188 """ 189 from jmri_loconet.clock import FAST_CLOCK_SLOT, decode_slot123 # noqa: PLC0415 190 from jmri_loconet.messages import OpcRqSlData, OpcSlRdData # noqa: PLC0415 191 192 if self._tc is None: 193 raise RuntimeError("connection not open") 194 tc = self._tc 195 loop = asyncio.get_event_loop() 196 fut: asyncio.Future[OpcSlRdData] = loop.create_future() 197 198 async def on_inbound(msg) -> None: # noqa: ANN001 199 if ( 200 isinstance(msg, OpcSlRdData) 201 and msg.slot == FAST_CLOCK_SLOT 202 and not fut.done() 203 ): 204 fut.set_result(msg) 205 206 unsub = tc.inbound.connect(on_inbound) 207 try: 208 await tc.send(OpcRqSlData(slot=FAST_CLOCK_SLOT)) 209 reply = await asyncio.wait_for(fut, timeout=2.0) 210 finally: 211 unsub() 212 return decode_slot123(reply) 213 214 async def _write_hardware_clock(self, state) -> None: # noqa: ANN001 215 """Common write path — reads the current slot to preserve 216 non-clock fields, merges *state*, and sends the write.""" 217 current = await self.read_hardware_clock() 218 # ``current`` gave us the CS's authoritative days / trk / snd 219 # bytes indirectly by reading the raw slot; we lose that in 220 # the decode. To keep this pass simple we send zeros for those 221 # fields — JMRI Java's LnClockControl does the same on write, 222 # relying on the CS to preserve day count internally. 223 from jmri_loconet.clock import build_slot123_write # noqa: PLC0415 224 225 merged = state.__class__( 226 hours=state.hours if state.hours is not None else current.hours, 227 minutes=state.minutes if state.minutes is not None else current.minutes, 228 seconds=state.seconds if state.seconds is not None else current.seconds, 229 rate=state.rate if state.rate is not None else current.rate, 230 running=state.running if state.running is not None else current.running, 231 twenty_four_hour=True, 232 ) 233 msg = build_slot123_write(merged) 234 if self._tc is None: 235 raise RuntimeError("connection not open") 236 await self._tc.send(msg) 237 238 async def set_hardware_clock_time( 239 self, hours: int, minutes: int, seconds: int = 0, 240 ) -> None: 241 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 242 current = await self.read_hardware_clock() 243 await self._write_hardware_clock(HardwareClockState( 244 hours=hours, minutes=minutes, seconds=seconds, 245 rate=current.rate, running=current.running, 246 twenty_four_hour=True, 247 )) 248 249 async def set_hardware_clock_rate(self, rate: float) -> None: 250 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 251 current = await self.read_hardware_clock() 252 await self._write_hardware_clock(HardwareClockState( 253 hours=current.hours, minutes=current.minutes, 254 seconds=current.seconds, 255 rate=rate, running=current.running, 256 twenty_four_hour=True, 257 )) 258 259 async def start_hardware_clock(self) -> None: 260 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 261 current = await self.read_hardware_clock() 262 # LocoNet uses rate=0 to indicate stopped, so "start" without a 263 # remembered rate defaults to 1× (real time). If the CS already 264 # has a stored non-zero rate we preserve it. 265 rate = current.rate if current.rate > 0 else 1.0 266 await self._write_hardware_clock(HardwareClockState( 267 hours=current.hours, minutes=current.minutes, 268 seconds=current.seconds, 269 rate=rate, running=True, 270 twenty_four_hour=True, 271 )) 272 273 async def stop_hardware_clock(self) -> None: 274 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 275 current = await self.read_hardware_clock() 276 await self._write_hardware_clock(HardwareClockState( 277 hours=current.hours, minutes=current.minutes, 278 seconds=current.seconds, 279 rate=current.rate, running=False, 280 twenty_four_hour=True, 281 )) 282 283 # ----- manager overrides ------------------------------------------------ 284 # We only customize turnouts, sensors, and power; everything else uses the 285 # base default in-memory managers (suitable since they don't talk to the 286 # hardware). Stage 4.5 can add LnLightManager, LnReporterManager, etc. 287 288 def _make_turnout_manager(self) -> TurnoutManager: # pragma: no cover - overridden in open 289 return TurnoutManager(self.prefix) 290 291 # Keep all other _make_*_manager hooks at the inherited in-memory defaults. 292 # The Manager generic type-cast below is just to keep mypy happy with the 293 # in-memory defaults that aren't LocoNet-aware. 294 _GENERIC_HINT: ClassVar[type[Manager[Any]]] = LightManager 295 _OTHER_HINTS: ClassVar[tuple[type, ...]] = ( 296 BlockManager, 297 MemoryManager, 298 PowerManager, 299 ReporterManager, 300 RouteManager, 301 SignalHeadManager, 302 SignalMastManager, 303 )
LocoNet hardware connection. Prefix L, name LocoNet.
72 async def open(self, settings: ConnectionSettings | None = None) -> None: 73 s = settings or {} 74 self._port = await _build_port(s) 75 self._tc = LocoNetTrafficController(self._port) 76 await self._tc.start() 77 self._slots = SlotManager(self._tc) 78 79 # Replace the base default managers with our LocoNet-aware ones. 80 # ``LnTurnoutManager`` and friends are subclasses of the base managers, 81 # but mypy's invariance in attribute typing means we have to ignore 82 # the assignment — the runtime substitution is sound (Liskov). 83 self.turnouts = LnTurnoutManager(self.prefix, tc=self._tc) # type: ignore[assignment] 84 self.sensors = LnSensorManager(self.prefix, tc=self._tc) # type: ignore[assignment] 85 throttle_mgr = LnThrottleManager( 86 self.prefix, tc=self._tc, slot_manager=self._slots, 87 ) 88 self.throttles = throttle_mgr 89 # Rebind the consist manager onto the LocoNet-aware throttle 90 # manager. Without this, consist fan-out drives placeholder 91 # Throttle beans left over from base ``__init__``, and no 92 # OPC_LOCO_SPD frames reach the wire. 93 self.consists.rebind_throttles(self.throttles) 94 throttle_mgr.start_refresh() 95 self.consists = LnConsistManager( # type: ignore[assignment] 96 throttle_mgr, self.prefix, tc=self._tc, slots=self._slots, 97 ) 98 self.power = LnPowerManager(self._tc) 99 self._programmer = LnProgrammer(self._tc) 100 self.programmers = LnProgrammerManager(self._programmer) 101 # Wire the advanced-consist CV19 write path to the LocoNet 102 # programmer manager. Without this rebind, ``advanced`` consists 103 # go through the base placeholder manager and CV19 never 104 # actually programs. 105 self.consists.rebind_programmers(self.programmers) 106 107 # CS reachability monitor. LocoNet CS emit OPC_IDLE and other 108 # frames continuously; 3s of complete silence is a strong 109 # signal the bus is dark (CS off or unplugged). Start 110 # pessimistic (base class default is optimistic True) so a 111 # home-page render during the monitor's startup grace shows 112 # "Off" until we've actually seen a frame, not a false "On". 113 self._cs_reachable = False 114 self._cs_monitor_task = asyncio.create_task( 115 self._cs_reachability_loop(), name="loconet-cs-reachability", 116 ) 117 118 await super().open(settings)
Connect to hardware and start any IO loops.
Concrete drivers acquire serial/TCP ports here, install protocol
handlers, and start any required background tasks. The base
implementation should be called via await super().open(settings)
to mark the connection opened.
146 async def close(self) -> None: 147 if isinstance(self.throttles, LnThrottleManager): 148 await self.throttles.stop_refresh() 149 if self._cs_monitor_task is not None: 150 self._cs_monitor_task.cancel() 151 import contextlib # noqa: PLC0415 152 with contextlib.suppress(asyncio.CancelledError, Exception): 153 await self._cs_monitor_task 154 self._cs_monitor_task = None 155 if self._tc is not None: 156 await self._tc.aclose() 157 self._tc = None 158 self._port = None 159 await super().close()
Disconnect from hardware. Idempotent. Base impl flips the flag.
177 async def read_hardware_clock(self): # noqa: ANN201 178 """Read the CS fast clock from slot 123. 179 180 Sends ``OPC_RQ_SL_DATA(slot=123)`` and awaits the paired 181 ``OPC_SL_RD_DATA`` reply on the inbound signal. Returns a 182 :class:`~jmri_core.beans.fast_clock.HardwareClockState`. 183 184 The :class:`~jmri_server.services.fast_clock.TimeService` 185 prefers this method over the software bean whenever the 186 connection exposes it, so wiring this on ``LocoNetConnection`` 187 automatically makes ``/json/time`` reflect the CS clock. 188 """ 189 from jmri_loconet.clock import FAST_CLOCK_SLOT, decode_slot123 # noqa: PLC0415 190 from jmri_loconet.messages import OpcRqSlData, OpcSlRdData # noqa: PLC0415 191 192 if self._tc is None: 193 raise RuntimeError("connection not open") 194 tc = self._tc 195 loop = asyncio.get_event_loop() 196 fut: asyncio.Future[OpcSlRdData] = loop.create_future() 197 198 async def on_inbound(msg) -> None: # noqa: ANN001 199 if ( 200 isinstance(msg, OpcSlRdData) 201 and msg.slot == FAST_CLOCK_SLOT 202 and not fut.done() 203 ): 204 fut.set_result(msg) 205 206 unsub = tc.inbound.connect(on_inbound) 207 try: 208 await tc.send(OpcRqSlData(slot=FAST_CLOCK_SLOT)) 209 reply = await asyncio.wait_for(fut, timeout=2.0) 210 finally: 211 unsub() 212 return decode_slot123(reply)
Read the CS fast clock from slot 123.
Sends OPC_RQ_SL_DATA(slot=123) and awaits the paired
OPC_SL_RD_DATA reply on the inbound signal. Returns a
~jmri_core.beans.fast_clock.HardwareClockState.
The ~jmri_server.services.fast_clock.TimeService
prefers this method over the software bean whenever the
connection exposes it, so wiring this on LocoNetConnection
automatically makes /json/time reflect the CS clock.
238 async def set_hardware_clock_time( 239 self, hours: int, minutes: int, seconds: int = 0, 240 ) -> None: 241 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 242 current = await self.read_hardware_clock() 243 await self._write_hardware_clock(HardwareClockState( 244 hours=hours, minutes=minutes, seconds=seconds, 245 rate=current.rate, running=current.running, 246 twenty_four_hour=True, 247 ))
249 async def set_hardware_clock_rate(self, rate: float) -> None: 250 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 251 current = await self.read_hardware_clock() 252 await self._write_hardware_clock(HardwareClockState( 253 hours=current.hours, minutes=current.minutes, 254 seconds=current.seconds, 255 rate=rate, running=current.running, 256 twenty_four_hour=True, 257 ))
259 async def start_hardware_clock(self) -> None: 260 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 261 current = await self.read_hardware_clock() 262 # LocoNet uses rate=0 to indicate stopped, so "start" without a 263 # remembered rate defaults to 1× (real time). If the CS already 264 # has a stored non-zero rate we preserve it. 265 rate = current.rate if current.rate > 0 else 1.0 266 await self._write_hardware_clock(HardwareClockState( 267 hours=current.hours, minutes=current.minutes, 268 seconds=current.seconds, 269 rate=rate, running=True, 270 twenty_four_hour=True, 271 ))
273 async def stop_hardware_clock(self) -> None: 274 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 275 current = await self.read_hardware_clock() 276 await self._write_hardware_clock(HardwareClockState( 277 hours=current.hours, minutes=current.minutes, 278 seconds=current.seconds, 279 rate=current.rate, running=False, 280 twenty_four_hour=True, 281 ))
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.
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.
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).
27class LocoNetTrafficController: 28 """The protocol-level conduit between transport bytes and typed messages. 29 30 Listeners connect to :attr:`inbound`; senders call :meth:`send`. The 31 read loop runs as a background asyncio task started by :meth:`start` 32 and cancelled by :meth:`aclose`. 33 """ 34 35 def __init__( 36 self, 37 port: LocoNetPort, 38 *, 39 min_send_interval: float = 0.005, 40 ) -> None: 41 self._port = port 42 self._min_send_interval = min_send_interval 43 self._send_lock = asyncio.Lock() 44 self._last_send: float = 0.0 45 self._read_task: asyncio.Task[None] | None = None 46 self.inbound: Signal[LocoNetMessage] = Signal() 47 self._closed = False 48 # Timestamp (``time.monotonic()``) of the most recent inbound 49 # frame. Read by :class:`LocoNetConnection`'s reachability 50 # monitor to flip CS-power state. LocoNet is chatty — the CS 51 # emits ``OPC_IDLE`` and other frames periodically — so a 52 # multi-second silence is a strong signal the bus is dark. 53 self.last_inbound_at: float | None = None 54 55 async def start(self) -> None: 56 if self._read_task is not None: 57 return 58 self._read_task = asyncio.create_task(self._read_loop(), name="loconet-read") 59 60 async def aclose(self) -> None: 61 self._closed = True 62 if self._read_task is not None: 63 self._read_task.cancel() 64 import contextlib # noqa: PLC0415 65 with contextlib.suppress(asyncio.CancelledError, Exception): 66 await self._read_task 67 self._read_task = None 68 await self._port.aclose() 69 70 async def send(self, msg: LocoNetMessage) -> None: 71 """Encode and write *msg* to the port, with interframe pacing.""" 72 frame = encode(msg) 73 async with self._send_lock: 74 now = asyncio.get_event_loop().time() 75 wait = self._min_send_interval - (now - self._last_send) 76 if wait > 0: 77 await asyncio.sleep(wait) 78 await self._port.write_frame(frame) 79 self._last_send = asyncio.get_event_loop().time() 80 81 # ----- internals -------------------------------------------------------- 82 83 async def _read_loop(self) -> None: 84 try: 85 while not self._closed: 86 try: 87 frame = await read_frame( 88 self._port.read_byte, self._port.read_exact 89 ) 90 except asyncio.CancelledError: 91 return 92 except Exception as e: # noqa: BLE001 93 _log.warning("LocoNet read error: %s", e) 94 continue 95 96 expected = frame_length(frame) 97 if expected is None or expected != len(frame): 98 _log.warning( 99 "LocoNet frame length mismatch: got %d bytes for %s", 100 len(frame), frame.hex() 101 ) 102 continue 103 104 try: 105 msg = decode(frame) 106 except ValueError as e: 107 _log.warning("LocoNet bad frame %s: %s", frame.hex(), e) 108 continue 109 110 # Any well-formed inbound frame counts as evidence the 111 # CS is alive — even a bare OPC_IDLE. The reachability 112 # monitor watches this timestamp. 113 self.last_inbound_at = time.monotonic() 114 await self.inbound.emit(msg) 115 except asyncio.CancelledError: 116 return
The protocol-level conduit between transport bytes and typed messages.
Listeners connect to inbound; senders call send(). The
read loop runs as a background asyncio task started by start()
and cancelled by aclose().
35 def __init__( 36 self, 37 port: LocoNetPort, 38 *, 39 min_send_interval: float = 0.005, 40 ) -> None: 41 self._port = port 42 self._min_send_interval = min_send_interval 43 self._send_lock = asyncio.Lock() 44 self._last_send: float = 0.0 45 self._read_task: asyncio.Task[None] | None = None 46 self.inbound: Signal[LocoNetMessage] = Signal() 47 self._closed = False 48 # Timestamp (``time.monotonic()``) of the most recent inbound 49 # frame. Read by :class:`LocoNetConnection`'s reachability 50 # monitor to flip CS-power state. LocoNet is chatty — the CS 51 # emits ``OPC_IDLE`` and other frames periodically — so a 52 # multi-second silence is a strong signal the bus is dark. 53 self.last_inbound_at: float | None = None
60 async def aclose(self) -> None: 61 self._closed = True 62 if self._read_task is not None: 63 self._read_task.cancel() 64 import contextlib # noqa: PLC0415 65 with contextlib.suppress(asyncio.CancelledError, Exception): 66 await self._read_task 67 self._read_task = None 68 await self._port.aclose()
70 async def send(self, msg: LocoNetMessage) -> None: 71 """Encode and write *msg* to the port, with interframe pacing.""" 72 frame = encode(msg) 73 async with self._send_lock: 74 now = asyncio.get_event_loop().time() 75 wait = self._min_send_interval - (now - self._last_send) 76 if wait > 0: 77 await asyncio.sleep(wait) 78 await self._port.write_frame(frame) 79 self._last_send = asyncio.get_event_loop().time()
Encode and write msg to the port, with interframe pacing.
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.
26 async def read_exact(self, n: int) -> bytes: 27 return bytes([await self._read.get() for _ in range(n)])
Read exactly n bytes.
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").
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).
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.
47@dataclass(slots=True) 48class Slot: 49 """Mutable mirror of one command-station slot.""" 50 51 number: int 52 address: int = 0 53 stat: int = 0 54 speed: int = 0 55 dirf: int = 0 56 snd: int = 0 57 trk: int = 0 58 59 @property 60 def in_use(self) -> bool: 61 return (self.stat & LOCO_STAT_MASK) == LOCO_IN_USE
Mutable mirror of one command-station slot.
64class SlotManager: 65 """Owns the per-slot state mirror and the acquire/release workflow.""" 66 67 def __init__(self, tc: LocoNetTrafficController) -> None: 68 self._tc = tc 69 self._slots: dict[int, Slot] = {} 70 self._by_address: dict[int, Slot] = {} 71 self._waiters: dict[int, asyncio.Future[Slot]] = {} 72 self._inbound_unsub = tc.inbound.connect(self._on_message) 73 74 # ----- acquire / release ----------------------------------------------- 75 76 async def acquire(self, dcc_address: int, *, timeout: float = 2.0) -> Slot: 77 """Acquire a slot for *dcc_address*. Returns the populated :class:`Slot`. 78 79 If we already hold a slot for the address, return it directly. Otherwise 80 send OPC_LOCO_ADR and wait for the command station's OPC_SL_RD_DATA 81 response — which arrives at us via the inbound listener. 82 """ 83 if dcc_address in self._by_address: 84 return self._by_address[dcc_address] 85 86 fut: asyncio.Future[Slot] = asyncio.get_event_loop().create_future() 87 self._waiters[dcc_address] = fut 88 await self._tc.send(OpcLocoAdr(address=dcc_address)) 89 try: 90 return await asyncio.wait_for(fut, timeout) 91 finally: 92 self._waiters.pop(dcc_address, None) 93 94 async def claim(self, slot: Slot) -> None: 95 """Send the null-move that flips the slot to LOCO_IN_USE.""" 96 await self._tc.send(OpcMoveSlots(src=slot.number, dest=slot.number)) 97 98 async def release(self, slot: Slot) -> None: 99 """Release the slot cleanly — mark it ``LOCO_COMMON`` so the 100 command station can purge it after its idle timeout without 101 losing the loco's speed/direction state. 102 103 The old implementation (``OPC_MOVE_SLOTS src=<n> dest=0``) 104 transferred slot ownership to slot 0, which real command 105 stations reinterpret as "dispatch this slot" — the loco 106 stops responding to us, keeps the same speed/direction, and 107 the CS starts purging it. Worse, the JMRI Java throttle uses 108 the *same* transient move-slot pattern to acquire a 109 dispatched slot, so an inadvertent release-then-reacquire 110 could steal the slot back before the CS actually purged it. 111 Matching JMRI Java's :func:`LocoNetSlot.releaseSlot` here. 112 113 Wire: ``OPC_SLOT_STAT1(slot=<n>, stat=(current & ~0x30) | 114 LOCO_COMMON)`` — clears the two loco-status bits and rewrites 115 them to LOCO_COMMON. Non-status bits (consist flags, 116 DCC-address type, etc.) are preserved. 117 """ 118 released_stat = (slot.stat & ~LOCO_STAT_MASK) | LOCO_COMMON 119 await self._tc.send( 120 OpcSlotStat1(slot=slot.number, stat=released_stat), 121 ) 122 self._by_address.pop(slot.address, None) 123 124 # ----- mirror access --------------------------------------------------- 125 126 def get(self, number: int) -> Slot | None: 127 return self._slots.get(number) 128 129 def by_address(self, address: int) -> Slot | None: 130 return self._by_address.get(address) 131 132 def __iter__(self) -> Iterator[Slot]: 133 return iter(self._slots.values()) 134 135 # ----- inbound listener ------------------------------------------------ 136 137 async def _on_message(self, msg: LocoNetMessage) -> None: 138 if not isinstance(msg, OpcSlRdData): 139 return 140 slot = self._slots.get(msg.slot) 141 if slot is None: 142 slot = Slot(number=msg.slot) 143 self._slots[msg.slot] = slot 144 # Update the mirror. 145 slot.stat = msg.stat 146 slot.speed = msg.speed 147 slot.dirf = msg.dirf 148 slot.snd = msg.snd 149 slot.trk = msg.trk 150 slot.address = msg.address 151 152 # Maintain the by-address index. 153 if msg.address: 154 self._by_address[msg.address] = slot 155 156 # Wake any acquire() waiting for this address. 157 if msg.address in self._waiters and not self._waiters[msg.address].done(): 158 self._waiters[msg.address].set_result(slot)
Owns the per-slot state mirror and the acquire/release workflow.
76 async def acquire(self, dcc_address: int, *, timeout: float = 2.0) -> Slot: 77 """Acquire a slot for *dcc_address*. Returns the populated :class:`Slot`. 78 79 If we already hold a slot for the address, return it directly. Otherwise 80 send OPC_LOCO_ADR and wait for the command station's OPC_SL_RD_DATA 81 response — which arrives at us via the inbound listener. 82 """ 83 if dcc_address in self._by_address: 84 return self._by_address[dcc_address] 85 86 fut: asyncio.Future[Slot] = asyncio.get_event_loop().create_future() 87 self._waiters[dcc_address] = fut 88 await self._tc.send(OpcLocoAdr(address=dcc_address)) 89 try: 90 return await asyncio.wait_for(fut, timeout) 91 finally: 92 self._waiters.pop(dcc_address, None)
Acquire a slot for dcc_address. Returns the populated Slot.
If we already hold a slot for the address, return it directly. Otherwise send OPC_LOCO_ADR and wait for the command station's OPC_SL_RD_DATA response — which arrives at us via the inbound listener.
94 async def claim(self, slot: Slot) -> None: 95 """Send the null-move that flips the slot to LOCO_IN_USE.""" 96 await self._tc.send(OpcMoveSlots(src=slot.number, dest=slot.number))
Send the null-move that flips the slot to LOCO_IN_USE.
98 async def release(self, slot: Slot) -> None: 99 """Release the slot cleanly — mark it ``LOCO_COMMON`` so the 100 command station can purge it after its idle timeout without 101 losing the loco's speed/direction state. 102 103 The old implementation (``OPC_MOVE_SLOTS src=<n> dest=0``) 104 transferred slot ownership to slot 0, which real command 105 stations reinterpret as "dispatch this slot" — the loco 106 stops responding to us, keeps the same speed/direction, and 107 the CS starts purging it. Worse, the JMRI Java throttle uses 108 the *same* transient move-slot pattern to acquire a 109 dispatched slot, so an inadvertent release-then-reacquire 110 could steal the slot back before the CS actually purged it. 111 Matching JMRI Java's :func:`LocoNetSlot.releaseSlot` here. 112 113 Wire: ``OPC_SLOT_STAT1(slot=<n>, stat=(current & ~0x30) | 114 LOCO_COMMON)`` — clears the two loco-status bits and rewrites 115 them to LOCO_COMMON. Non-status bits (consist flags, 116 DCC-address type, etc.) are preserved. 117 """ 118 released_stat = (slot.stat & ~LOCO_STAT_MASK) | LOCO_COMMON 119 await self._tc.send( 120 OpcSlotStat1(slot=slot.number, stat=released_stat), 121 ) 122 self._by_address.pop(slot.address, None)
Release the slot cleanly — mark it LOCO_COMMON so the
command station can purge it after its idle timeout without
losing the loco's speed/direction state.
The old implementation (OPC_MOVE_SLOTS src=<n> dest=0)
transferred slot ownership to slot 0, which real command
stations reinterpret as "dispatch this slot" — the loco
stops responding to us, keeps the same speed/direction, and
the CS starts purging it. Worse, the JMRI Java throttle uses
the same transient move-slot pattern to acquire a
dispatched slot, so an inadvertent release-then-reacquire
could steal the slot back before the CS actually purged it.
Matching JMRI Java's LocoNetSlot.releaseSlot() here.
Wire: OPC_SLOT_STAT1(slot=<n>, stat=(current & ~0x30) |
LOCO_COMMON) — clears the two loco-status bits and rewrites
them to LOCO_COMMON. Non-status bits (consist flags,
DCC-address type, etc.) are preserved.
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).
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.