jmri_withrottle.server
WiThrottleServer — accept TCP connections, run a session each.
Usage from a host app::
server = WiThrottleServer(connection=conn, roster_provider=lambda: [...])
await server.start(host="0.0.0.0", port=12090)
...
await server.stop()
The server is independent of FastAPI — it holds its own asyncio
listener — but it's typically mounted inside the same process as
jmri_server so it shares the bean managers and roster.
1"""``WiThrottleServer`` — accept TCP connections, run a session each. 2 3Usage from a host app:: 4 5 server = WiThrottleServer(connection=conn, roster_provider=lambda: [...]) 6 await server.start(host="0.0.0.0", port=12090) 7 ... 8 await server.stop() 9 10The server is independent of FastAPI — it holds its own asyncio 11listener — but it's typically mounted inside the same process as 12:mod:`jmri_server` so it shares the bean managers and roster. 13""" 14from __future__ import annotations 15 16import asyncio 17import logging 18from collections.abc import Awaitable, Callable 19from dataclasses import dataclass, field 20 21from jmri_core.beans.turnout import Turnout, TurnoutStateChange 22from jmri_core.hal.connection import SystemConnection 23 24from jmri_withrottle import protocol 25from jmri_withrottle.session import WiThrottleSession 26 27_log = logging.getLogger(__name__) 28 29DEFAULT_PORT = 12090 30 31 32@dataclass 33class WiThrottleServer: 34 """TCP server speaking the WiThrottle line protocol.""" 35 36 connection: SystemConnection 37 # Callable so the server can be told about roster + power state 38 # without us hard-wiring to jmri-server. Returns a list of dicts 39 # in the roster JSON shape (id, dccAddress, longAddress). 40 roster_provider: Callable[[], list[dict]] = field(default_factory=lambda: list) 41 # Callable returning the current fast-clock ``PFT`` sync line, or 42 # ``None`` if no fast clock is attached. Called on connect and on 43 # every heartbeat so Engine Driver / WiThrottle iOS clocks stay in 44 # sync without needing a separate broadcast path. 45 fast_clock_provider: Callable[[], str | None] = field( 46 default_factory=lambda: (lambda: None), 47 ) 48 # Callable returning the current turnout inventory as a list of 49 # ``{"system_name", "user_name", "state"}`` dicts. Sent once per 50 # session (after the roster) so Engine Driver / WiThrottle iOS can 51 # populate their Turnouts tab. 52 turnout_provider: Callable[[], list[dict]] = field( 53 default_factory=lambda: list, 54 ) 55 # Handler for turnout throw/close/toggle commands from the client. 56 # Called as ``await handler(system_name, action)`` where action is 57 # one of ``"T"``, ``"C"``, or ``"2"``. If None, turnout actions from 58 # the client are silently dropped. 59 turnout_action_handler: ( 60 Callable[[str, str], Awaitable[None]] | None 61 ) = None 62 railroad: str = "Python JMRI" 63 heartbeat_s: int = protocol.DEFAULT_HEARTBEAT_S 64 web_port: int = 8000 65 # How often to push a fresh PFT to every connected client. Engine 66 # Driver's clock extrapolates from the last sync using the rate; 67 # 5 s keeps it within a couple of fast-seconds of the server after 68 # rate/state changes without spamming the wire. 69 fast_clock_broadcast_s: float = 5.0 70 _server: asyncio.Server | None = None 71 _sessions: set[WiThrottleSession] = field(default_factory=set) 72 _pft_task: asyncio.Task[None] | None = None 73 # Disconnect callables from ``state_changed`` subscriptions and 74 # the turnout manager's ``added``/``removed`` signals. Filled on 75 # ``start()``, drained on ``stop()``. 76 _turnout_unsubs: list[Callable[[], None]] = field(default_factory=list) 77 78 # ----- lifecycle ----------------------------------------------------- 79 80 async def start(self, host: str = "0.0.0.0", port: int = DEFAULT_PORT) -> None: 81 if self._server is not None: 82 return 83 self._server = await asyncio.start_server( 84 self._handle, host=host, port=port, 85 ) 86 self._pft_task = asyncio.create_task( 87 self._pft_broadcast_loop(), name="withrottle-pft-broadcast", 88 ) 89 # Wire turnout state broadcasts: every existing turnout gets a 90 # ``state_changed`` listener that fires a PTA line to every 91 # connected client. ``added``/``removed`` on the manager keep 92 # the subscription set in sync so turnouts created later (via 93 # the JSON API, web UI, or a driver monitor) also broadcast. 94 # Must run in ``start()`` (post-``conn.open()``), not at 95 # construction — drivers swap in their own turnout manager 96 # during ``open()`` and any earlier subscription would leak to 97 # the base manager. 98 for t in self.connection.turnouts: 99 self._turnout_unsubs.append(self._subscribe_turnout(t)) 100 self._turnout_unsubs.append( 101 self.connection.turnouts.added.connect(self._on_turnout_added) 102 ) 103 _log.info("WiThrottle server listening on %s:%d", host, port) 104 105 def _subscribe_turnout(self, t: Turnout) -> Callable[[], None]: 106 """Attach a ``state_changed`` listener to *t* that pushes PTA.""" 107 async def _listener(change: TurnoutStateChange) -> None: 108 # Only ``known`` transitions produce a wire-visible state on 109 # the Turnouts tab; commanded transitions still show the 110 # previous known state until the layout confirms. 111 if change.kind != "known": 112 return 113 await self.broadcast_turnout_state( 114 t.system_name, int(change.current), 115 ) 116 return t.state_changed.connect(_listener) 117 118 async def _on_turnout_added(self, t: Turnout) -> None: 119 self._turnout_unsubs.append(self._subscribe_turnout(t)) 120 121 async def stop(self) -> None: 122 if self._server is None: 123 return 124 if self._pft_task is not None: 125 self._pft_task.cancel() 126 import contextlib # noqa: PLC0415 127 with contextlib.suppress(asyncio.CancelledError, Exception): 128 await self._pft_task 129 self._pft_task = None 130 for unsub in self._turnout_unsubs: 131 try: 132 unsub() 133 except Exception as e: # noqa: BLE001 134 _log.debug("turnout unsubscribe failed: %s", e) 135 self._turnout_unsubs.clear() 136 for s in list(self._sessions): 137 await s.close() 138 self._server.close() 139 await self._server.wait_closed() 140 self._server = None 141 _log.info("WiThrottle server stopped") 142 143 async def broadcast_turnout_state( 144 self, system_name: str, state: int, 145 ) -> None: 146 """Push a ``PTA<state><sysName>`` line to every connected session. 147 148 The jmri_server wiring subscribes to each turnout's ``state_changed`` 149 signal and calls this so the WiThrottle Turnouts tab stays in 150 sync with layout state — whether the change came from another 151 WiThrottle client, the web UI, or a physical cab. 152 """ 153 if not self._sessions: 154 return 155 line = protocol.turnout_state(system_name, state) 156 for sess in list(self._sessions): 157 try: 158 await sess.send_line(line) 159 except Exception as e: # noqa: BLE001 160 _log.debug("turnout broadcast to session failed: %s", e) 161 162 async def _pft_broadcast_loop(self) -> None: 163 """Periodically push the current PFT to every session. 164 165 Engine Driver clocks extrapolate locally from the last sync line, 166 but if the server-side clock state changes (start, stop, rate 167 change, big time jump) the client can drift until the next sync. 168 A ~15 s broadcast keeps every client aligned within one fast-clock 169 second at 8x rate, with negligible wire cost. 170 """ 171 try: 172 while True: 173 await asyncio.sleep(self.fast_clock_broadcast_s) 174 pft = self.fast_clock_provider() 175 if pft is None or not self._sessions: 176 continue 177 for sess in list(self._sessions): 178 try: 179 await sess.send_line(pft) 180 except Exception as e: # noqa: BLE001 181 _log.debug("PFT broadcast to session failed: %s", e) 182 except asyncio.CancelledError: 183 return 184 185 @property 186 def is_running(self) -> bool: 187 return self._server is not None 188 189 @property 190 def sockets(self) -> list: 191 """Bound sockets — useful in tests to discover the assigned port.""" 192 if self._server is None: 193 return [] 194 return list(self._server.sockets) 195 196 # ----- per-connection handler ---------------------------------------- 197 198 async def _handle( 199 self, 200 reader: asyncio.StreamReader, 201 writer: asyncio.StreamWriter, 202 ) -> None: 203 peer = writer.get_extra_info("peername") 204 _log.info("WiThrottle client connected: %s", peer) 205 206 async def send_line(line: str) -> None: 207 try: 208 writer.write((line + "\n").encode("utf-8")) 209 await writer.drain() 210 except (ConnectionResetError, BrokenPipeError): 211 pass 212 213 session = WiThrottleSession( 214 throttle_mgr=self.connection.throttles, 215 send_line=send_line, 216 consist_mgr=getattr(self.connection, "consists", None), 217 fast_clock_provider=self.fast_clock_provider, 218 turnout_action_handler=self.turnout_action_handler, 219 ) 220 self._sessions.add(session) 221 try: 222 # Greeting + initial state. 223 for line in protocol.greeting( 224 name=self.railroad, 225 heartbeat_s=self.heartbeat_s, 226 web_port=self.web_port, 227 ): 228 await send_line(line) 229 roster = self.roster_provider() 230 if roster: 231 await send_line(protocol.roster_list(roster)) 232 # Push the initial fast-clock reading so the client's clock 233 # doesn't sit at 12:00 PM waiting for the first heartbeat. 234 pft = self.fast_clock_provider() 235 if pft is not None: 236 await send_line(pft) 237 # Turnout state labels + inventory so the Turnouts tab renders. 238 # PTT (titles) must precede PTL (list) so the client already 239 # knows how to render each numeric state code. We skip both 240 # when the connection has no turnouts to avoid confusing 241 # clients that hide the Turnouts tab on an empty PTT. 242 turnouts = self.turnout_provider() 243 if turnouts: 244 await send_line(protocol.turnout_titles()) 245 await send_line(protocol.turnout_list(turnouts)) 246 247 # Read loop. 248 while True: 249 raw = await reader.readline() 250 if not raw: 251 break 252 line = raw.decode("utf-8", errors="replace").strip() 253 if not line: 254 continue 255 keep_going = await session.dispatch(line) 256 if not keep_going: 257 break 258 except asyncio.CancelledError: 259 raise 260 except Exception as e: # noqa: BLE001 261 _log.warning("WiThrottle session error from %s: %s", peer, e) 262 finally: 263 await session.close() 264 self._sessions.discard(session) 265 writer.close() 266 with _suppress_close(): 267 await writer.wait_closed() 268 _log.info("WiThrottle client disconnected: %s", peer) 269 270 271class _suppress_close: 272 def __enter__(self) -> None: ... 273 def __exit__(self, exc_type, exc, tb) -> bool: # noqa: ANN001 274 # Swallow close-time errors; the connection's already gone. 275 return exc_type is not None and issubclass( 276 exc_type, (ConnectionError, OSError), 277 )
DEFAULT_PORT =
12090
@dataclass
class
WiThrottleServer:
33@dataclass 34class WiThrottleServer: 35 """TCP server speaking the WiThrottle line protocol.""" 36 37 connection: SystemConnection 38 # Callable so the server can be told about roster + power state 39 # without us hard-wiring to jmri-server. Returns a list of dicts 40 # in the roster JSON shape (id, dccAddress, longAddress). 41 roster_provider: Callable[[], list[dict]] = field(default_factory=lambda: list) 42 # Callable returning the current fast-clock ``PFT`` sync line, or 43 # ``None`` if no fast clock is attached. Called on connect and on 44 # every heartbeat so Engine Driver / WiThrottle iOS clocks stay in 45 # sync without needing a separate broadcast path. 46 fast_clock_provider: Callable[[], str | None] = field( 47 default_factory=lambda: (lambda: None), 48 ) 49 # Callable returning the current turnout inventory as a list of 50 # ``{"system_name", "user_name", "state"}`` dicts. Sent once per 51 # session (after the roster) so Engine Driver / WiThrottle iOS can 52 # populate their Turnouts tab. 53 turnout_provider: Callable[[], list[dict]] = field( 54 default_factory=lambda: list, 55 ) 56 # Handler for turnout throw/close/toggle commands from the client. 57 # Called as ``await handler(system_name, action)`` where action is 58 # one of ``"T"``, ``"C"``, or ``"2"``. If None, turnout actions from 59 # the client are silently dropped. 60 turnout_action_handler: ( 61 Callable[[str, str], Awaitable[None]] | None 62 ) = None 63 railroad: str = "Python JMRI" 64 heartbeat_s: int = protocol.DEFAULT_HEARTBEAT_S 65 web_port: int = 8000 66 # How often to push a fresh PFT to every connected client. Engine 67 # Driver's clock extrapolates from the last sync using the rate; 68 # 5 s keeps it within a couple of fast-seconds of the server after 69 # rate/state changes without spamming the wire. 70 fast_clock_broadcast_s: float = 5.0 71 _server: asyncio.Server | None = None 72 _sessions: set[WiThrottleSession] = field(default_factory=set) 73 _pft_task: asyncio.Task[None] | None = None 74 # Disconnect callables from ``state_changed`` subscriptions and 75 # the turnout manager's ``added``/``removed`` signals. Filled on 76 # ``start()``, drained on ``stop()``. 77 _turnout_unsubs: list[Callable[[], None]] = field(default_factory=list) 78 79 # ----- lifecycle ----------------------------------------------------- 80 81 async def start(self, host: str = "0.0.0.0", port: int = DEFAULT_PORT) -> None: 82 if self._server is not None: 83 return 84 self._server = await asyncio.start_server( 85 self._handle, host=host, port=port, 86 ) 87 self._pft_task = asyncio.create_task( 88 self._pft_broadcast_loop(), name="withrottle-pft-broadcast", 89 ) 90 # Wire turnout state broadcasts: every existing turnout gets a 91 # ``state_changed`` listener that fires a PTA line to every 92 # connected client. ``added``/``removed`` on the manager keep 93 # the subscription set in sync so turnouts created later (via 94 # the JSON API, web UI, or a driver monitor) also broadcast. 95 # Must run in ``start()`` (post-``conn.open()``), not at 96 # construction — drivers swap in their own turnout manager 97 # during ``open()`` and any earlier subscription would leak to 98 # the base manager. 99 for t in self.connection.turnouts: 100 self._turnout_unsubs.append(self._subscribe_turnout(t)) 101 self._turnout_unsubs.append( 102 self.connection.turnouts.added.connect(self._on_turnout_added) 103 ) 104 _log.info("WiThrottle server listening on %s:%d", host, port) 105 106 def _subscribe_turnout(self, t: Turnout) -> Callable[[], None]: 107 """Attach a ``state_changed`` listener to *t* that pushes PTA.""" 108 async def _listener(change: TurnoutStateChange) -> None: 109 # Only ``known`` transitions produce a wire-visible state on 110 # the Turnouts tab; commanded transitions still show the 111 # previous known state until the layout confirms. 112 if change.kind != "known": 113 return 114 await self.broadcast_turnout_state( 115 t.system_name, int(change.current), 116 ) 117 return t.state_changed.connect(_listener) 118 119 async def _on_turnout_added(self, t: Turnout) -> None: 120 self._turnout_unsubs.append(self._subscribe_turnout(t)) 121 122 async def stop(self) -> None: 123 if self._server is None: 124 return 125 if self._pft_task is not None: 126 self._pft_task.cancel() 127 import contextlib # noqa: PLC0415 128 with contextlib.suppress(asyncio.CancelledError, Exception): 129 await self._pft_task 130 self._pft_task = None 131 for unsub in self._turnout_unsubs: 132 try: 133 unsub() 134 except Exception as e: # noqa: BLE001 135 _log.debug("turnout unsubscribe failed: %s", e) 136 self._turnout_unsubs.clear() 137 for s in list(self._sessions): 138 await s.close() 139 self._server.close() 140 await self._server.wait_closed() 141 self._server = None 142 _log.info("WiThrottle server stopped") 143 144 async def broadcast_turnout_state( 145 self, system_name: str, state: int, 146 ) -> None: 147 """Push a ``PTA<state><sysName>`` line to every connected session. 148 149 The jmri_server wiring subscribes to each turnout's ``state_changed`` 150 signal and calls this so the WiThrottle Turnouts tab stays in 151 sync with layout state — whether the change came from another 152 WiThrottle client, the web UI, or a physical cab. 153 """ 154 if not self._sessions: 155 return 156 line = protocol.turnout_state(system_name, state) 157 for sess in list(self._sessions): 158 try: 159 await sess.send_line(line) 160 except Exception as e: # noqa: BLE001 161 _log.debug("turnout broadcast to session failed: %s", e) 162 163 async def _pft_broadcast_loop(self) -> None: 164 """Periodically push the current PFT to every session. 165 166 Engine Driver clocks extrapolate locally from the last sync line, 167 but if the server-side clock state changes (start, stop, rate 168 change, big time jump) the client can drift until the next sync. 169 A ~15 s broadcast keeps every client aligned within one fast-clock 170 second at 8x rate, with negligible wire cost. 171 """ 172 try: 173 while True: 174 await asyncio.sleep(self.fast_clock_broadcast_s) 175 pft = self.fast_clock_provider() 176 if pft is None or not self._sessions: 177 continue 178 for sess in list(self._sessions): 179 try: 180 await sess.send_line(pft) 181 except Exception as e: # noqa: BLE001 182 _log.debug("PFT broadcast to session failed: %s", e) 183 except asyncio.CancelledError: 184 return 185 186 @property 187 def is_running(self) -> bool: 188 return self._server is not None 189 190 @property 191 def sockets(self) -> list: 192 """Bound sockets — useful in tests to discover the assigned port.""" 193 if self._server is None: 194 return [] 195 return list(self._server.sockets) 196 197 # ----- per-connection handler ---------------------------------------- 198 199 async def _handle( 200 self, 201 reader: asyncio.StreamReader, 202 writer: asyncio.StreamWriter, 203 ) -> None: 204 peer = writer.get_extra_info("peername") 205 _log.info("WiThrottle client connected: %s", peer) 206 207 async def send_line(line: str) -> None: 208 try: 209 writer.write((line + "\n").encode("utf-8")) 210 await writer.drain() 211 except (ConnectionResetError, BrokenPipeError): 212 pass 213 214 session = WiThrottleSession( 215 throttle_mgr=self.connection.throttles, 216 send_line=send_line, 217 consist_mgr=getattr(self.connection, "consists", None), 218 fast_clock_provider=self.fast_clock_provider, 219 turnout_action_handler=self.turnout_action_handler, 220 ) 221 self._sessions.add(session) 222 try: 223 # Greeting + initial state. 224 for line in protocol.greeting( 225 name=self.railroad, 226 heartbeat_s=self.heartbeat_s, 227 web_port=self.web_port, 228 ): 229 await send_line(line) 230 roster = self.roster_provider() 231 if roster: 232 await send_line(protocol.roster_list(roster)) 233 # Push the initial fast-clock reading so the client's clock 234 # doesn't sit at 12:00 PM waiting for the first heartbeat. 235 pft = self.fast_clock_provider() 236 if pft is not None: 237 await send_line(pft) 238 # Turnout state labels + inventory so the Turnouts tab renders. 239 # PTT (titles) must precede PTL (list) so the client already 240 # knows how to render each numeric state code. We skip both 241 # when the connection has no turnouts to avoid confusing 242 # clients that hide the Turnouts tab on an empty PTT. 243 turnouts = self.turnout_provider() 244 if turnouts: 245 await send_line(protocol.turnout_titles()) 246 await send_line(protocol.turnout_list(turnouts)) 247 248 # Read loop. 249 while True: 250 raw = await reader.readline() 251 if not raw: 252 break 253 line = raw.decode("utf-8", errors="replace").strip() 254 if not line: 255 continue 256 keep_going = await session.dispatch(line) 257 if not keep_going: 258 break 259 except asyncio.CancelledError: 260 raise 261 except Exception as e: # noqa: BLE001 262 _log.warning("WiThrottle session error from %s: %s", peer, e) 263 finally: 264 await session.close() 265 self._sessions.discard(session) 266 writer.close() 267 with _suppress_close(): 268 await writer.wait_closed() 269 _log.info("WiThrottle client disconnected: %s", peer)
TCP server speaking the WiThrottle line protocol.
WiThrottleServer( connection: jmri_core.hal.SystemConnection, roster_provider: Callable[[], list[dict]] = <factory>, fast_clock_provider: Callable[[], str | None] = <factory>, turnout_provider: Callable[[], list[dict]] = <factory>, turnout_action_handler: Callable[[str, str], Awaitable[None]] | None = None, railroad: str = 'Python JMRI', heartbeat_s: int = 10, web_port: int = 8000, fast_clock_broadcast_s: float = 5.0, _server: asyncio.base_events.Server | None = None, _sessions: set[jmri_withrottle.session.WiThrottleSession] = <factory>, _pft_task: _asyncio.Task[None] | None = None, _turnout_unsubs: list[Callable[[], None]] = <factory>)
connection: jmri_core.hal.SystemConnection
async def
start(self, host: str = '0.0.0.0', port: int = 12090) -> None:
81 async def start(self, host: str = "0.0.0.0", port: int = DEFAULT_PORT) -> None: 82 if self._server is not None: 83 return 84 self._server = await asyncio.start_server( 85 self._handle, host=host, port=port, 86 ) 87 self._pft_task = asyncio.create_task( 88 self._pft_broadcast_loop(), name="withrottle-pft-broadcast", 89 ) 90 # Wire turnout state broadcasts: every existing turnout gets a 91 # ``state_changed`` listener that fires a PTA line to every 92 # connected client. ``added``/``removed`` on the manager keep 93 # the subscription set in sync so turnouts created later (via 94 # the JSON API, web UI, or a driver monitor) also broadcast. 95 # Must run in ``start()`` (post-``conn.open()``), not at 96 # construction — drivers swap in their own turnout manager 97 # during ``open()`` and any earlier subscription would leak to 98 # the base manager. 99 for t in self.connection.turnouts: 100 self._turnout_unsubs.append(self._subscribe_turnout(t)) 101 self._turnout_unsubs.append( 102 self.connection.turnouts.added.connect(self._on_turnout_added) 103 ) 104 _log.info("WiThrottle server listening on %s:%d", host, port)
async def
stop(self) -> None:
122 async def stop(self) -> None: 123 if self._server is None: 124 return 125 if self._pft_task is not None: 126 self._pft_task.cancel() 127 import contextlib # noqa: PLC0415 128 with contextlib.suppress(asyncio.CancelledError, Exception): 129 await self._pft_task 130 self._pft_task = None 131 for unsub in self._turnout_unsubs: 132 try: 133 unsub() 134 except Exception as e: # noqa: BLE001 135 _log.debug("turnout unsubscribe failed: %s", e) 136 self._turnout_unsubs.clear() 137 for s in list(self._sessions): 138 await s.close() 139 self._server.close() 140 await self._server.wait_closed() 141 self._server = None 142 _log.info("WiThrottle server stopped")
async def
broadcast_turnout_state(self, system_name: str, state: int) -> None:
144 async def broadcast_turnout_state( 145 self, system_name: str, state: int, 146 ) -> None: 147 """Push a ``PTA<state><sysName>`` line to every connected session. 148 149 The jmri_server wiring subscribes to each turnout's ``state_changed`` 150 signal and calls this so the WiThrottle Turnouts tab stays in 151 sync with layout state — whether the change came from another 152 WiThrottle client, the web UI, or a physical cab. 153 """ 154 if not self._sessions: 155 return 156 line = protocol.turnout_state(system_name, state) 157 for sess in list(self._sessions): 158 try: 159 await sess.send_line(line) 160 except Exception as e: # noqa: BLE001 161 _log.debug("turnout broadcast to session failed: %s", e)
Push a PTA<state><sysName> line to every connected session.
The jmri_server wiring subscribes to each turnout's state_changed
signal and calls this so the WiThrottle Turnouts tab stays in
sync with layout state — whether the change came from another
WiThrottle client, the web UI, or a physical cab.