jmri_withrottle.session
WiThrottleSession — one connected client.
Holds the per-connection state (client name, acquired throttles,
last-heartbeat timestamp) and bridges WiThrottle commands to the
active connection's ThrottleManager. The TCP server in
server.py owns one session per accepted socket.
1"""``WiThrottleSession`` — one connected client. 2 3Holds the per-connection state (client name, acquired throttles, 4last-heartbeat timestamp) and bridges WiThrottle commands to the 5active connection's :class:`ThrottleManager`. The TCP server in 6``server.py`` owns one session per accepted socket. 7""" 8from __future__ import annotations 9 10import logging 11from collections.abc import Awaitable, Callable 12from dataclasses import dataclass, field 13 14from jmri_core.beans.consist_manager import ConsistManager 15from jmri_core.beans.throttle import Throttle 16from jmri_core.beans.throttle_manager import ThrottleManager 17from jmri_core.state import ThrottleDirection 18 19from jmri_withrottle import protocol 20 21_log = logging.getLogger(__name__) 22 23# Speed comes in as 0..126 on WiThrottle (1 is the e-stop sentinel, 24# 0 is stop, 2..126 is motion). Our throttle bean uses 0.0..1.0. 25WITHROTTLE_SPEED_MAX = 126 26 27 28SendLine = Callable[[str], Awaitable[None]] 29 30# Handler for turnout throw/close/toggle commanded from a WiThrottle client. 31# Called as ``await handler(system_name, action)`` where action is one of 32# ``"T"`` (throw), ``"C"`` (close), or ``"2"`` (toggle). 33TurnoutActionHandler = Callable[[str, str], Awaitable[None]] 34 35 36@dataclass(slots=True) 37class _Acquired: 38 throttle_char: str # "T" or "S" 39 address_token: str # raw client-supplied token 40 bean: Throttle # the underlying core Throttle 41 # When the acquired address matches a server-side consist, we 42 # remember it here so throttle actions (V / R / F / X) dispatch 43 # through the consist manager's fan-out instead of driving only 44 # the lead's :class:`Throttle`. Software consists on any driver 45 # then reach every member; CS-managed consists on LocoNet keep 46 # working via LINK. 47 consist_address: int | None = None 48 49 50@dataclass(eq=False) # identity-based eq+hash so set() tracking works 51class WiThrottleSession: 52 """One WiThrottle client connection.""" 53 54 throttle_mgr: ThrottleManager 55 send_line: SendLine # writer to push lines back 56 consist_mgr: ConsistManager | None = None 57 fast_clock_provider: Callable[[], str | None] | None = None 58 turnout_action_handler: TurnoutActionHandler | None = None 59 client_name: str = "" 60 hardware_id: str = "" 61 _acquired: dict[tuple[str, str], _Acquired] = field(default_factory=dict) 62 63 # ----- entry point ---------------------------------------------------- 64 65 async def dispatch(self, line: str) -> bool: 66 """Process one inbound line. Returns False if the client said Quit.""" 67 if line and _log.isEnabledFor(logging.DEBUG): 68 _log.debug("WiThrottle IN: %r", line) 69 msg = protocol.decode_line(line) 70 if isinstance(msg, protocol.Heartbeat): 71 # Engine Driver and WiThrottle iOS treat the heartbeat as 72 # ping/pong — if the server doesn't echo, the client thinks 73 # the link is dead and tears down the TCP session. Always 74 # echo back. 75 await self.send_line("*") 76 # Piggyback a fast-clock resync on the heartbeat so the 77 # client's clock stays aligned without needing a separate 78 # broadcast timer. 79 if self.fast_clock_provider is not None: 80 pft = self.fast_clock_provider() 81 if pft is not None: 82 await self.send_line(pft) 83 return True 84 if isinstance(msg, protocol.Quit): 85 await self._release_all() 86 return False 87 if isinstance(msg, protocol.HelloName): 88 self.client_name = msg.name 89 return True 90 if isinstance(msg, protocol.HelloHardwareId): 91 self.hardware_id = msg.hardware_id 92 return True 93 if isinstance(msg, protocol.AcquireLoco): 94 await self._acquire(msg) 95 return True 96 if isinstance(msg, protocol.ReleaseLoco): 97 await self._release(msg.throttle, msg.address_token) 98 return True 99 if isinstance(msg, protocol.ThrottleAction): 100 await self._action(msg) 101 return True 102 if isinstance(msg, protocol.TurnoutAction): 103 if self.turnout_action_handler is not None: 104 try: 105 await self.turnout_action_handler(msg.system_name, msg.action) 106 except Exception as e: # noqa: BLE001 107 _log.warning( 108 "WiThrottle turnout %s action %r failed: %s", 109 msg.system_name, msg.action, e, 110 ) 111 return True 112 if isinstance(msg, protocol.Unknown): 113 if msg.raw: 114 _log.debug("WiThrottle unknown line: %r", msg.raw) 115 return True 116 return True 117 118 # ----- acquire / release --------------------------------------------- 119 120 async def _acquire(self, msg: protocol.AcquireLoco) -> None: 121 try: 122 addr, is_long = protocol.parse_address_token(msg.address_token) 123 except ValueError: 124 _log.warning("WiThrottle bad address token: %r", msg.address_token) 125 return 126 # Consist first-check: if the address matches a server-side 127 # consist, we still acquire a Throttle for the lead so the 128 # client's initial state (function labels, speed, direction) 129 # renders normally, but subsequent throttle actions fan through 130 # the consist manager to every member. Engine Driver / 131 # WiThrottle iOS have no wire-level notion of "this is a 132 # consist" — MU support is a server-side transparent thing. 133 consist_addr: int | None = None 134 if self.consist_mgr is not None: 135 consist = self.consist_mgr.get(addr) 136 if consist is not None and consist.locos: 137 consist_addr = addr 138 # Use the lead loco's address for the underlying 139 # throttle acquire so the client sees the lead's 140 # function/speed state. 141 lead = consist.locos[0] 142 addr = lead.address 143 is_long = lead.long_address 144 try: 145 bean = await self.throttle_mgr.acquire(addr, long_address=is_long) 146 except Exception as e: # noqa: BLE001 147 _log.warning("WiThrottle acquire %s failed: %s", msg.address_token, e) 148 return 149 self._acquired[(msg.throttle, msg.address_token)] = _Acquired( 150 throttle_char=msg.throttle, 151 address_token=msg.address_token, 152 bean=bean, 153 consist_address=consist_addr, 154 ) 155 await self.send_line( 156 protocol.throttle_acquired(msg.throttle, msg.address_token) 157 ) 158 # Broadcast the throttle's initial state so Engine Driver / 159 # WiThrottle iOS know how to render its buttons. Without the 160 # per-function ``F<state><n>`` lines the client doesn't display 161 # the function bar at all; without the initial V / R the slider 162 # sits at an undefined position. 163 await self._broadcast_initial_state( 164 msg.throttle, msg.address_token, bean, 165 ) 166 167 async def _broadcast_initial_state( 168 self, throttle: str, address_token: str, bean: Throttle, 169 ) -> None: 170 """Send the state lines Engine Driver expects after an acquire ack. 171 172 Order matches JMRI's ``ThrottleController.notifyControllerAddressFound``. 173 174 The label line matters and must be present with non-empty labels: 175 176 * Roster acquires (``M+L<addr><;>E<id>``) hide the function bar 177 if we skip the labels line — the client expects roster 178 metadata and interprets a missing line as "no functions". 179 * Manual acquires (``M+L<addr><;>L<addr>``) hide the bar if we 180 send *empty* labels — the client interprets those as "loco 181 has no functions". Non-empty defaults (``F0``..``F28``) work 182 for both cases. 183 184 Order sent: 185 186 1. ``M<mux>L<addr><;>]\\[F0]\\[F1]\\[...`` — function labels 187 2. ``F<state><fn>`` — one line per F0..F28 (function states) 188 3. ``V<speed>`` — initial speed (0..126) 189 4. ``R<0|1>`` — initial direction 190 5. ``s<mode>`` — speed step mode (1 = 128-step NMRA DCC) 191 """ 192 # Labels first, populated with default F0..F28 so both roster 193 # and manual acquisitions render the function bar. 194 await self.send_line( 195 protocol.throttle_function_labels(throttle, address_token) 196 ) 197 # Function states next — must precede speed/direction or the 198 # client hides the function bar. 199 for fn in range(protocol.MAX_FUNCTION + 1): 200 await self.send_line( 201 protocol.throttle_function( 202 throttle, address_token, fn, bean.functions[fn], 203 ) 204 ) 205 speed_wire = int(round(WITHROTTLE_SPEED_MAX * max(0.0, bean.speed))) 206 await self.send_line( 207 protocol.throttle_speed(throttle, address_token, speed_wire) 208 ) 209 forward = bean.direction == ThrottleDirection.FORWARD 210 await self.send_line( 211 protocol.throttle_direction(throttle, address_token, forward) 212 ) 213 # Speed step mode — 1 = NMRA DCC 128-step (the modern default; 214 # NCE, LocoNet, and DCC-EX all speak this). 215 await self.send_line( 216 protocol.throttle_speed_step_mode(throttle, address_token, 1) 217 ) 218 219 async def _release(self, throttle: str, address_token: str) -> None: 220 key = (throttle, address_token) 221 a = self._acquired.pop(key, None) 222 if a is None: 223 return 224 try: 225 await self.throttle_mgr.release(a.bean) 226 except Exception as e: # noqa: BLE001 227 _log.warning("WiThrottle release %s failed: %s", address_token, e) 228 await self.send_line(protocol.throttle_released(throttle, address_token)) 229 230 async def _release_all(self) -> None: 231 for (throttle, token) in list(self._acquired): 232 await self._release(throttle, token) 233 234 # ----- per-throttle actions ------------------------------------------ 235 236 async def _action(self, msg: protocol.ThrottleAction) -> None: 237 # WiThrottle allows a broadcast address token ``*`` meaning 238 # "every throttle currently acquired on this multiplex 239 # character". Engine Driver uses this for slider drags — so 240 # speed commands arrive as ``M0A*<;>V29`` while direction 241 # arrives as ``M0AL463<;>R0``. Without ``*`` support, direction 242 # works but speed doesn't (the bug we saw on hardware). 243 if msg.address_token == "*": 244 targets = [ 245 acq for (t, _tok), acq in self._acquired.items() 246 if t == msg.throttle 247 ] 248 else: 249 target = self._acquired.get((msg.throttle, msg.address_token)) 250 targets = [target] if target is not None else [] 251 if not targets: 252 return 253 for a in targets: 254 await self._apply_action(a, msg) 255 256 async def _apply_action( 257 self, a: _Acquired, msg: protocol.ThrottleAction, 258 ) -> None: 259 op = msg.action 260 if op == "V": 261 try: 262 wire = int(msg.value) 263 except ValueError: 264 return 265 await self._set_speed(a, wire) 266 elif op == "R": 267 forward = msg.value == "1" 268 direction = ( 269 ThrottleDirection.FORWARD if forward 270 else ThrottleDirection.REVERSE 271 ) 272 if a.consist_address is not None and self.consist_mgr is not None: 273 await self.consist_mgr.set_direction(a.consist_address, direction) 274 else: 275 await a.bean.set_direction(direction) 276 await self.send_line( 277 protocol.throttle_direction(a.throttle_char, a.address_token, forward) 278 ) 279 elif op in ("F", "f"): 280 # Format: state then function number; e.g. "F128" = pressed F28, 281 # "F01" = released F1. "F" is press/release toggle, "f" is 282 # force-set (which we treat the same for alpha). 283 if not msg.value: 284 return 285 state = msg.value[0] == "1" 286 try: 287 fn = int(msg.value[1:]) 288 except ValueError: 289 return 290 if not 0 <= fn <= protocol.MAX_FUNCTION: 291 return 292 if a.consist_address is not None and self.consist_mgr is not None: 293 await self.consist_mgr.set_function(a.consist_address, fn, state) 294 else: 295 await a.bean.set_function(fn, state) 296 await self.send_line( 297 protocol.throttle_function(a.throttle_char, a.address_token, fn, state) 298 ) 299 elif op == "X": 300 # E-stop: full reverse direction on JMRI is encoded as speed 301 # ``-1`` in our bean; the wire encoding goes via the same 302 # set_speed path. Use the bean's emergency_stop helper. 303 if a.consist_address is not None and self.consist_mgr is not None: 304 await self.consist_mgr.emergency_stop(a.consist_address) 305 else: 306 await a.bean.emergency_stop() 307 await self.send_line( 308 protocol.throttle_speed(a.throttle_char, a.address_token, 1) 309 ) 310 elif op == "I": 311 # Idle: speed 0 without changing direction. 312 if a.consist_address is not None and self.consist_mgr is not None: 313 await self.consist_mgr.set_speed(a.consist_address, 0.0) 314 else: 315 await a.bean.set_speed(0.0) 316 await self.send_line( 317 protocol.throttle_speed(a.throttle_char, a.address_token, 0) 318 ) 319 # "L" (speed-step mode) accepted silently for alpha. 320 321 async def _set_speed(self, a: _Acquired, wire_speed: int) -> None: 322 if wire_speed < 0: 323 wire_speed = 0 324 if wire_speed > WITHROTTLE_SPEED_MAX: 325 wire_speed = WITHROTTLE_SPEED_MAX 326 # Speed target: fan through the consist manager when this 327 # acquired throttle is bound to a server-side consist. 328 if a.consist_address is not None and self.consist_mgr is not None: 329 if wire_speed == 0: 330 await self.consist_mgr.set_speed(a.consist_address, 0.0) 331 elif wire_speed == 1: 332 await self.consist_mgr.emergency_stop(a.consist_address) 333 else: 334 fraction = (wire_speed - 1) / (WITHROTTLE_SPEED_MAX - 1) 335 await self.consist_mgr.set_speed(a.consist_address, fraction) 336 elif wire_speed <= 1: 337 # 0 = stop, 1 = e-stop sentinel; both map to the same throttle bean call. 338 if wire_speed == 0: 339 await a.bean.set_speed(0.0) 340 else: 341 await a.bean.emergency_stop() 342 else: 343 # Map 2..126 onto 0.0..1.0 linearly. 344 fraction = (wire_speed - 1) / (WITHROTTLE_SPEED_MAX - 1) 345 await a.bean.set_speed(fraction) 346 await self.send_line( 347 protocol.throttle_speed(a.throttle_char, a.address_token, wire_speed) 348 ) 349 350 # ----- cleanup ------------------------------------------------------- 351 352 async def close(self) -> None: 353 """Release all acquired throttles. Called when the socket goes away.""" 354 await self._release_all()
WITHROTTLE_SPEED_MAX =
126
SendLine =
collections.abc.Callable[[str], collections.abc.Awaitable[None]]
TurnoutActionHandler =
collections.abc.Callable[[str, str], collections.abc.Awaitable[None]]
@dataclass(eq=False)
class
WiThrottleSession:
51@dataclass(eq=False) # identity-based eq+hash so set() tracking works 52class WiThrottleSession: 53 """One WiThrottle client connection.""" 54 55 throttle_mgr: ThrottleManager 56 send_line: SendLine # writer to push lines back 57 consist_mgr: ConsistManager | None = None 58 fast_clock_provider: Callable[[], str | None] | None = None 59 turnout_action_handler: TurnoutActionHandler | None = None 60 client_name: str = "" 61 hardware_id: str = "" 62 _acquired: dict[tuple[str, str], _Acquired] = field(default_factory=dict) 63 64 # ----- entry point ---------------------------------------------------- 65 66 async def dispatch(self, line: str) -> bool: 67 """Process one inbound line. Returns False if the client said Quit.""" 68 if line and _log.isEnabledFor(logging.DEBUG): 69 _log.debug("WiThrottle IN: %r", line) 70 msg = protocol.decode_line(line) 71 if isinstance(msg, protocol.Heartbeat): 72 # Engine Driver and WiThrottle iOS treat the heartbeat as 73 # ping/pong — if the server doesn't echo, the client thinks 74 # the link is dead and tears down the TCP session. Always 75 # echo back. 76 await self.send_line("*") 77 # Piggyback a fast-clock resync on the heartbeat so the 78 # client's clock stays aligned without needing a separate 79 # broadcast timer. 80 if self.fast_clock_provider is not None: 81 pft = self.fast_clock_provider() 82 if pft is not None: 83 await self.send_line(pft) 84 return True 85 if isinstance(msg, protocol.Quit): 86 await self._release_all() 87 return False 88 if isinstance(msg, protocol.HelloName): 89 self.client_name = msg.name 90 return True 91 if isinstance(msg, protocol.HelloHardwareId): 92 self.hardware_id = msg.hardware_id 93 return True 94 if isinstance(msg, protocol.AcquireLoco): 95 await self._acquire(msg) 96 return True 97 if isinstance(msg, protocol.ReleaseLoco): 98 await self._release(msg.throttle, msg.address_token) 99 return True 100 if isinstance(msg, protocol.ThrottleAction): 101 await self._action(msg) 102 return True 103 if isinstance(msg, protocol.TurnoutAction): 104 if self.turnout_action_handler is not None: 105 try: 106 await self.turnout_action_handler(msg.system_name, msg.action) 107 except Exception as e: # noqa: BLE001 108 _log.warning( 109 "WiThrottle turnout %s action %r failed: %s", 110 msg.system_name, msg.action, e, 111 ) 112 return True 113 if isinstance(msg, protocol.Unknown): 114 if msg.raw: 115 _log.debug("WiThrottle unknown line: %r", msg.raw) 116 return True 117 return True 118 119 # ----- acquire / release --------------------------------------------- 120 121 async def _acquire(self, msg: protocol.AcquireLoco) -> None: 122 try: 123 addr, is_long = protocol.parse_address_token(msg.address_token) 124 except ValueError: 125 _log.warning("WiThrottle bad address token: %r", msg.address_token) 126 return 127 # Consist first-check: if the address matches a server-side 128 # consist, we still acquire a Throttle for the lead so the 129 # client's initial state (function labels, speed, direction) 130 # renders normally, but subsequent throttle actions fan through 131 # the consist manager to every member. Engine Driver / 132 # WiThrottle iOS have no wire-level notion of "this is a 133 # consist" — MU support is a server-side transparent thing. 134 consist_addr: int | None = None 135 if self.consist_mgr is not None: 136 consist = self.consist_mgr.get(addr) 137 if consist is not None and consist.locos: 138 consist_addr = addr 139 # Use the lead loco's address for the underlying 140 # throttle acquire so the client sees the lead's 141 # function/speed state. 142 lead = consist.locos[0] 143 addr = lead.address 144 is_long = lead.long_address 145 try: 146 bean = await self.throttle_mgr.acquire(addr, long_address=is_long) 147 except Exception as e: # noqa: BLE001 148 _log.warning("WiThrottle acquire %s failed: %s", msg.address_token, e) 149 return 150 self._acquired[(msg.throttle, msg.address_token)] = _Acquired( 151 throttle_char=msg.throttle, 152 address_token=msg.address_token, 153 bean=bean, 154 consist_address=consist_addr, 155 ) 156 await self.send_line( 157 protocol.throttle_acquired(msg.throttle, msg.address_token) 158 ) 159 # Broadcast the throttle's initial state so Engine Driver / 160 # WiThrottle iOS know how to render its buttons. Without the 161 # per-function ``F<state><n>`` lines the client doesn't display 162 # the function bar at all; without the initial V / R the slider 163 # sits at an undefined position. 164 await self._broadcast_initial_state( 165 msg.throttle, msg.address_token, bean, 166 ) 167 168 async def _broadcast_initial_state( 169 self, throttle: str, address_token: str, bean: Throttle, 170 ) -> None: 171 """Send the state lines Engine Driver expects after an acquire ack. 172 173 Order matches JMRI's ``ThrottleController.notifyControllerAddressFound``. 174 175 The label line matters and must be present with non-empty labels: 176 177 * Roster acquires (``M+L<addr><;>E<id>``) hide the function bar 178 if we skip the labels line — the client expects roster 179 metadata and interprets a missing line as "no functions". 180 * Manual acquires (``M+L<addr><;>L<addr>``) hide the bar if we 181 send *empty* labels — the client interprets those as "loco 182 has no functions". Non-empty defaults (``F0``..``F28``) work 183 for both cases. 184 185 Order sent: 186 187 1. ``M<mux>L<addr><;>]\\[F0]\\[F1]\\[...`` — function labels 188 2. ``F<state><fn>`` — one line per F0..F28 (function states) 189 3. ``V<speed>`` — initial speed (0..126) 190 4. ``R<0|1>`` — initial direction 191 5. ``s<mode>`` — speed step mode (1 = 128-step NMRA DCC) 192 """ 193 # Labels first, populated with default F0..F28 so both roster 194 # and manual acquisitions render the function bar. 195 await self.send_line( 196 protocol.throttle_function_labels(throttle, address_token) 197 ) 198 # Function states next — must precede speed/direction or the 199 # client hides the function bar. 200 for fn in range(protocol.MAX_FUNCTION + 1): 201 await self.send_line( 202 protocol.throttle_function( 203 throttle, address_token, fn, bean.functions[fn], 204 ) 205 ) 206 speed_wire = int(round(WITHROTTLE_SPEED_MAX * max(0.0, bean.speed))) 207 await self.send_line( 208 protocol.throttle_speed(throttle, address_token, speed_wire) 209 ) 210 forward = bean.direction == ThrottleDirection.FORWARD 211 await self.send_line( 212 protocol.throttle_direction(throttle, address_token, forward) 213 ) 214 # Speed step mode — 1 = NMRA DCC 128-step (the modern default; 215 # NCE, LocoNet, and DCC-EX all speak this). 216 await self.send_line( 217 protocol.throttle_speed_step_mode(throttle, address_token, 1) 218 ) 219 220 async def _release(self, throttle: str, address_token: str) -> None: 221 key = (throttle, address_token) 222 a = self._acquired.pop(key, None) 223 if a is None: 224 return 225 try: 226 await self.throttle_mgr.release(a.bean) 227 except Exception as e: # noqa: BLE001 228 _log.warning("WiThrottle release %s failed: %s", address_token, e) 229 await self.send_line(protocol.throttle_released(throttle, address_token)) 230 231 async def _release_all(self) -> None: 232 for (throttle, token) in list(self._acquired): 233 await self._release(throttle, token) 234 235 # ----- per-throttle actions ------------------------------------------ 236 237 async def _action(self, msg: protocol.ThrottleAction) -> None: 238 # WiThrottle allows a broadcast address token ``*`` meaning 239 # "every throttle currently acquired on this multiplex 240 # character". Engine Driver uses this for slider drags — so 241 # speed commands arrive as ``M0A*<;>V29`` while direction 242 # arrives as ``M0AL463<;>R0``. Without ``*`` support, direction 243 # works but speed doesn't (the bug we saw on hardware). 244 if msg.address_token == "*": 245 targets = [ 246 acq for (t, _tok), acq in self._acquired.items() 247 if t == msg.throttle 248 ] 249 else: 250 target = self._acquired.get((msg.throttle, msg.address_token)) 251 targets = [target] if target is not None else [] 252 if not targets: 253 return 254 for a in targets: 255 await self._apply_action(a, msg) 256 257 async def _apply_action( 258 self, a: _Acquired, msg: protocol.ThrottleAction, 259 ) -> None: 260 op = msg.action 261 if op == "V": 262 try: 263 wire = int(msg.value) 264 except ValueError: 265 return 266 await self._set_speed(a, wire) 267 elif op == "R": 268 forward = msg.value == "1" 269 direction = ( 270 ThrottleDirection.FORWARD if forward 271 else ThrottleDirection.REVERSE 272 ) 273 if a.consist_address is not None and self.consist_mgr is not None: 274 await self.consist_mgr.set_direction(a.consist_address, direction) 275 else: 276 await a.bean.set_direction(direction) 277 await self.send_line( 278 protocol.throttle_direction(a.throttle_char, a.address_token, forward) 279 ) 280 elif op in ("F", "f"): 281 # Format: state then function number; e.g. "F128" = pressed F28, 282 # "F01" = released F1. "F" is press/release toggle, "f" is 283 # force-set (which we treat the same for alpha). 284 if not msg.value: 285 return 286 state = msg.value[0] == "1" 287 try: 288 fn = int(msg.value[1:]) 289 except ValueError: 290 return 291 if not 0 <= fn <= protocol.MAX_FUNCTION: 292 return 293 if a.consist_address is not None and self.consist_mgr is not None: 294 await self.consist_mgr.set_function(a.consist_address, fn, state) 295 else: 296 await a.bean.set_function(fn, state) 297 await self.send_line( 298 protocol.throttle_function(a.throttle_char, a.address_token, fn, state) 299 ) 300 elif op == "X": 301 # E-stop: full reverse direction on JMRI is encoded as speed 302 # ``-1`` in our bean; the wire encoding goes via the same 303 # set_speed path. Use the bean's emergency_stop helper. 304 if a.consist_address is not None and self.consist_mgr is not None: 305 await self.consist_mgr.emergency_stop(a.consist_address) 306 else: 307 await a.bean.emergency_stop() 308 await self.send_line( 309 protocol.throttle_speed(a.throttle_char, a.address_token, 1) 310 ) 311 elif op == "I": 312 # Idle: speed 0 without changing direction. 313 if a.consist_address is not None and self.consist_mgr is not None: 314 await self.consist_mgr.set_speed(a.consist_address, 0.0) 315 else: 316 await a.bean.set_speed(0.0) 317 await self.send_line( 318 protocol.throttle_speed(a.throttle_char, a.address_token, 0) 319 ) 320 # "L" (speed-step mode) accepted silently for alpha. 321 322 async def _set_speed(self, a: _Acquired, wire_speed: int) -> None: 323 if wire_speed < 0: 324 wire_speed = 0 325 if wire_speed > WITHROTTLE_SPEED_MAX: 326 wire_speed = WITHROTTLE_SPEED_MAX 327 # Speed target: fan through the consist manager when this 328 # acquired throttle is bound to a server-side consist. 329 if a.consist_address is not None and self.consist_mgr is not None: 330 if wire_speed == 0: 331 await self.consist_mgr.set_speed(a.consist_address, 0.0) 332 elif wire_speed == 1: 333 await self.consist_mgr.emergency_stop(a.consist_address) 334 else: 335 fraction = (wire_speed - 1) / (WITHROTTLE_SPEED_MAX - 1) 336 await self.consist_mgr.set_speed(a.consist_address, fraction) 337 elif wire_speed <= 1: 338 # 0 = stop, 1 = e-stop sentinel; both map to the same throttle bean call. 339 if wire_speed == 0: 340 await a.bean.set_speed(0.0) 341 else: 342 await a.bean.emergency_stop() 343 else: 344 # Map 2..126 onto 0.0..1.0 linearly. 345 fraction = (wire_speed - 1) / (WITHROTTLE_SPEED_MAX - 1) 346 await a.bean.set_speed(fraction) 347 await self.send_line( 348 protocol.throttle_speed(a.throttle_char, a.address_token, wire_speed) 349 ) 350 351 # ----- cleanup ------------------------------------------------------- 352 353 async def close(self) -> None: 354 """Release all acquired throttles. Called when the socket goes away.""" 355 await self._release_all()
One WiThrottle client connection.
WiThrottleSession( throttle_mgr: jmri_core.beans.ThrottleManager, send_line: Callable[[str], Awaitable[None]], consist_mgr: jmri_core.beans.ConsistManager | None = None, fast_clock_provider: Callable[[], str | None] | None = None, turnout_action_handler: Callable[[str, str], Awaitable[None]] | None = None, client_name: str = '', hardware_id: str = '', _acquired: dict[tuple[str, str], jmri_withrottle.session._Acquired] = <factory>)
throttle_mgr: jmri_core.beans.ThrottleManager
async def
dispatch(self, line: str) -> bool:
66 async def dispatch(self, line: str) -> bool: 67 """Process one inbound line. Returns False if the client said Quit.""" 68 if line and _log.isEnabledFor(logging.DEBUG): 69 _log.debug("WiThrottle IN: %r", line) 70 msg = protocol.decode_line(line) 71 if isinstance(msg, protocol.Heartbeat): 72 # Engine Driver and WiThrottle iOS treat the heartbeat as 73 # ping/pong — if the server doesn't echo, the client thinks 74 # the link is dead and tears down the TCP session. Always 75 # echo back. 76 await self.send_line("*") 77 # Piggyback a fast-clock resync on the heartbeat so the 78 # client's clock stays aligned without needing a separate 79 # broadcast timer. 80 if self.fast_clock_provider is not None: 81 pft = self.fast_clock_provider() 82 if pft is not None: 83 await self.send_line(pft) 84 return True 85 if isinstance(msg, protocol.Quit): 86 await self._release_all() 87 return False 88 if isinstance(msg, protocol.HelloName): 89 self.client_name = msg.name 90 return True 91 if isinstance(msg, protocol.HelloHardwareId): 92 self.hardware_id = msg.hardware_id 93 return True 94 if isinstance(msg, protocol.AcquireLoco): 95 await self._acquire(msg) 96 return True 97 if isinstance(msg, protocol.ReleaseLoco): 98 await self._release(msg.throttle, msg.address_token) 99 return True 100 if isinstance(msg, protocol.ThrottleAction): 101 await self._action(msg) 102 return True 103 if isinstance(msg, protocol.TurnoutAction): 104 if self.turnout_action_handler is not None: 105 try: 106 await self.turnout_action_handler(msg.system_name, msg.action) 107 except Exception as e: # noqa: BLE001 108 _log.warning( 109 "WiThrottle turnout %s action %r failed: %s", 110 msg.system_name, msg.action, e, 111 ) 112 return True 113 if isinstance(msg, protocol.Unknown): 114 if msg.raw: 115 _log.debug("WiThrottle unknown line: %r", msg.raw) 116 return True 117 return True
Process one inbound line. Returns False if the client said Quit.