jmri_server.services.fast_clock
TimeService — JSON wire interface for the model-railroad Fast Clock.
JMRI exposes the fast clock under the time topic. Wire shape:
method=get→{"time": "HH:MM", "rate": float, "running": bool}method=post,data={"rate": float, "running": bool, "time": "HH:MM"}→ applies any provided field; returns the new state. All fields optional.
When the active connection has an authoritative command-station clock
(NCE PowerPro, LocoNet with LNCP, ...) the driver exposes async methods
read_hardware_clock / set_hardware_clock_time /
set_hardware_clock_rate / start_hardware_clock /
stop_hardware_clock. The service prefers those over the software
FastClock bean so Get returns the CS's real time and
Apply pushes changes down to the command station.
Absent hardware support, the software bean is the source of truth. Its state persists across GET/POST calls on the same server process; a restart resets to midnight / 1× / stopped (the JMRI default).
1"""``TimeService`` — JSON wire interface for the model-railroad Fast Clock. 2 3JMRI exposes the fast clock under the ``time`` topic. Wire shape: 4 5* ``method=get`` → ``{"time": "HH:MM", "rate": float, "running": bool}`` 6* ``method=post``, ``data={"rate": float, "running": bool, "time": "HH:MM"}`` 7 → applies any provided field; returns the new state. All fields optional. 8 9When the active connection has an authoritative command-station clock 10(NCE PowerPro, LocoNet with LNCP, ...) the driver exposes async methods 11``read_hardware_clock`` / ``set_hardware_clock_time`` / 12``set_hardware_clock_rate`` / ``start_hardware_clock`` / 13``stop_hardware_clock``. The service prefers those over the software 14:class:`FastClock` bean so ``Get`` returns the CS's real time and 15``Apply`` pushes changes down to the command station. 16 17Absent hardware support, the software bean is the source of truth. Its 18state persists across GET/POST calls on the same server process; a 19restart resets to midnight / 1× / stopped (the JMRI default). 20""" 21from __future__ import annotations 22 23import logging 24import time 25from typing import Any 26 27from jmri_core.beans.fast_clock import FastClock, HardwareClockState 28 29from jmri_server.service import JsonService, JsonServiceError 30 31_log = logging.getLogger(__name__) 32 33# How long a successful hardware-clock read stays authoritative. The web 34# widget polls at 1 Hz; without a cache we'd hit the CS every second and 35# starve co-tenant readers (turnout monitor, AIU poller). 5 s is enough 36# for the software bean to advance visibly between resyncs. 37HARDWARE_CLOCK_CACHE_S = 5.0 38 39 40def _project_state(hours: int, minutes: int, rate: float, running: bool) -> dict[str, Any]: 41 return { 42 "time": f"{hours:02d}:{minutes:02d}", 43 "rate": rate, 44 "running": running, 45 } 46 47 48def _project(clock: FastClock) -> dict[str, Any]: 49 h, m = clock.time_hhmm() 50 return _project_state(h, m, clock.rate, clock.running) 51 52 53def _project_hardware(state: HardwareClockState) -> dict[str, Any]: 54 return _project_state(state.hours, state.minutes, state.rate, state.running) 55 56 57def _get_or_create_clock(ctx: Any) -> FastClock: 58 """Singleton :class:`FastClock` on the server context.""" 59 clock = getattr(ctx, "fast_clock", None) 60 if clock is None: 61 clock = FastClock() 62 ctx.fast_clock = clock # type: ignore[attr-defined] 63 return clock 64 65 66def _hardware_clock(ctx: Any) -> Any | None: 67 """The active connection if it has a CS-driven fast clock, else None.""" 68 conn = getattr(ctx, "connection", None) 69 if conn is None or not getattr(conn, "is_open", False): 70 return None 71 return conn if hasattr(conn, "read_hardware_clock") else None 72 73 74def _mirror_to_bean(clock: FastClock, state: HardwareClockState) -> None: 75 """Reset the software bean anchor so its display shows the CS time. 76 77 Between GET calls the WebSocket poll re-renders from the bean; without 78 this the bean would drift back to midnight while the widget waits 79 for the next Get click. 80 """ 81 # Use the underlying fields directly — set_time/start/stop would emit 82 # events and clip on validation. This is an internal cache update. 83 clock.rate = state.rate 84 clock.running = state.running 85 clock._base_seconds = ( 86 state.hours * 3600 + state.minutes * 60 + state.seconds 87 ) 88 clock._real_anchor = time.monotonic() 89 90 91def _cache_is_fresh(ctx: Any) -> bool: 92 """Have we successfully read the CS within ``HARDWARE_CLOCK_CACHE_S``?""" 93 ts = getattr(ctx, "_hw_clock_read_at", None) 94 return isinstance(ts, float) and (time.monotonic() - ts) < HARDWARE_CLOCK_CACHE_S 95 96 97def _invalidate_cache(ctx: Any) -> None: 98 if hasattr(ctx, "_hw_clock_read_at"): 99 delattr(ctx, "_hw_clock_read_at") 100 101 102def _record_cache(ctx: Any) -> None: 103 ctx._hw_clock_read_at = time.monotonic() # type: ignore[attr-defined] 104 105 106def _parse_hhmm(value: str) -> tuple[int, int]: 107 parts = value.split(":") 108 if len(parts) != 2: 109 raise JsonServiceError(400, f"time must be 'HH:MM', got {value!r}") 110 try: 111 hours = int(parts[0]) 112 minutes = int(parts[1]) 113 except ValueError: 114 raise JsonServiceError(400, f"time must be 'HH:MM', got {value!r}") from None 115 return hours, minutes 116 117 118class TimeService(JsonService): 119 """Fast Clock JSON service. Topic: ``time``.""" 120 121 topic = "time" 122 123 async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]: 124 conn = _hardware_clock(self.ctx) 125 clock = _get_or_create_clock(self.ctx) 126 if conn is not None: 127 # When the CS cache is fresh, return the software bean — 128 # which was mirrored from the last CS read and continues to 129 # advance locally. This bounds CS-side traffic to one poll 130 # per ``HARDWARE_CLOCK_CACHE_S`` so co-tenant readers (turnout 131 # monitor, AIU poller) keep their airtime. 132 if _cache_is_fresh(self.ctx): 133 return _project(clock) 134 try: 135 state = await conn.read_hardware_clock() 136 except (TimeoutError, OSError) as e: 137 _log.warning("hardware clock read failed, using bean: %s", e) 138 else: 139 _mirror_to_bean(clock, state) 140 _record_cache(self.ctx) 141 return _project_hardware(state) 142 return _project(clock) 143 144 async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]: 145 data = data or {} 146 clock = _get_or_create_clock(self.ctx) 147 conn = _hardware_clock(self.ctx) 148 149 # Apply each field if present. On hardware-backed connections, 150 # write through to the CS *first* — the software bean cache is 151 # then refreshed from a follow-up read. Hardware writes can time 152 # out under serial-link contention; surface those as a 504-ish 153 # JsonServiceError instead of a bare 500 so the UI can show a 154 # graceful message. 155 if "rate" in data: 156 try: 157 rate = float(data["rate"]) 158 except (TypeError, ValueError): 159 raise JsonServiceError(400, "rate must be numeric") from None 160 if conn is not None: 161 try: 162 await conn.set_hardware_clock_rate(rate) 163 except TimeoutError: 164 raise JsonServiceError( 165 504, "command station did not acknowledge the rate change" 166 ) from None 167 try: 168 await clock.set_rate(rate) 169 except ValueError as e: 170 raise JsonServiceError(400, str(e)) from None 171 172 if "time" in data: 173 value = data["time"] 174 if not isinstance(value, str): 175 raise JsonServiceError(400, "time must be 'HH:MM' string") 176 hours, minutes = _parse_hhmm(value) 177 if conn is not None: 178 try: 179 await conn.set_hardware_clock_time(hours, minutes, 0) 180 except TimeoutError: 181 raise JsonServiceError( 182 504, "command station did not acknowledge the time change" 183 ) from None 184 try: 185 await clock.set_time(hours, minutes) 186 except ValueError as e: 187 raise JsonServiceError(400, str(e)) from None 188 189 if "running" in data: 190 run = bool(data["running"]) 191 if conn is not None: 192 try: 193 if run: 194 await conn.start_hardware_clock() 195 else: 196 await conn.stop_hardware_clock() 197 except TimeoutError: 198 raise JsonServiceError( 199 504, "command station did not acknowledge start/stop" 200 ) from None 201 if run: 202 await clock.start() 203 else: 204 await clock.stop() 205 206 # Re-project from the CS if we have one so the caller sees the 207 # authoritative state, not the (possibly stale) bean. A write 208 # always invalidates the cache — the caller needs the ground 209 # truth to know its update took effect. 210 if conn is not None: 211 _invalidate_cache(self.ctx) 212 try: 213 state = await conn.read_hardware_clock() 214 except (TimeoutError, OSError) as e: 215 _log.warning("hardware clock read after write failed: %s", e) 216 else: 217 _mirror_to_bean(clock, state) 218 _record_cache(self.ctx) 219 return _project_hardware(state) 220 return _project(clock)
119class TimeService(JsonService): 120 """Fast Clock JSON service. Topic: ``time``.""" 121 122 topic = "time" 123 124 async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]: 125 conn = _hardware_clock(self.ctx) 126 clock = _get_or_create_clock(self.ctx) 127 if conn is not None: 128 # When the CS cache is fresh, return the software bean — 129 # which was mirrored from the last CS read and continues to 130 # advance locally. This bounds CS-side traffic to one poll 131 # per ``HARDWARE_CLOCK_CACHE_S`` so co-tenant readers (turnout 132 # monitor, AIU poller) keep their airtime. 133 if _cache_is_fresh(self.ctx): 134 return _project(clock) 135 try: 136 state = await conn.read_hardware_clock() 137 except (TimeoutError, OSError) as e: 138 _log.warning("hardware clock read failed, using bean: %s", e) 139 else: 140 _mirror_to_bean(clock, state) 141 _record_cache(self.ctx) 142 return _project_hardware(state) 143 return _project(clock) 144 145 async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]: 146 data = data or {} 147 clock = _get_or_create_clock(self.ctx) 148 conn = _hardware_clock(self.ctx) 149 150 # Apply each field if present. On hardware-backed connections, 151 # write through to the CS *first* — the software bean cache is 152 # then refreshed from a follow-up read. Hardware writes can time 153 # out under serial-link contention; surface those as a 504-ish 154 # JsonServiceError instead of a bare 500 so the UI can show a 155 # graceful message. 156 if "rate" in data: 157 try: 158 rate = float(data["rate"]) 159 except (TypeError, ValueError): 160 raise JsonServiceError(400, "rate must be numeric") from None 161 if conn is not None: 162 try: 163 await conn.set_hardware_clock_rate(rate) 164 except TimeoutError: 165 raise JsonServiceError( 166 504, "command station did not acknowledge the rate change" 167 ) from None 168 try: 169 await clock.set_rate(rate) 170 except ValueError as e: 171 raise JsonServiceError(400, str(e)) from None 172 173 if "time" in data: 174 value = data["time"] 175 if not isinstance(value, str): 176 raise JsonServiceError(400, "time must be 'HH:MM' string") 177 hours, minutes = _parse_hhmm(value) 178 if conn is not None: 179 try: 180 await conn.set_hardware_clock_time(hours, minutes, 0) 181 except TimeoutError: 182 raise JsonServiceError( 183 504, "command station did not acknowledge the time change" 184 ) from None 185 try: 186 await clock.set_time(hours, minutes) 187 except ValueError as e: 188 raise JsonServiceError(400, str(e)) from None 189 190 if "running" in data: 191 run = bool(data["running"]) 192 if conn is not None: 193 try: 194 if run: 195 await conn.start_hardware_clock() 196 else: 197 await conn.stop_hardware_clock() 198 except TimeoutError: 199 raise JsonServiceError( 200 504, "command station did not acknowledge start/stop" 201 ) from None 202 if run: 203 await clock.start() 204 else: 205 await clock.stop() 206 207 # Re-project from the CS if we have one so the caller sees the 208 # authoritative state, not the (possibly stale) bean. A write 209 # always invalidates the cache — the caller needs the ground 210 # truth to know its update took effect. 211 if conn is not None: 212 _invalidate_cache(self.ctx) 213 try: 214 state = await conn.read_hardware_clock() 215 except (TimeoutError, OSError) as e: 216 _log.warning("hardware clock read after write failed: %s", e) 217 else: 218 _mirror_to_bean(clock, state) 219 _record_cache(self.ctx) 220 return _project_hardware(state) 221 return _project(clock)
Fast Clock JSON service. Topic: time.
124 async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]: 125 conn = _hardware_clock(self.ctx) 126 clock = _get_or_create_clock(self.ctx) 127 if conn is not None: 128 # When the CS cache is fresh, return the software bean — 129 # which was mirrored from the last CS read and continues to 130 # advance locally. This bounds CS-side traffic to one poll 131 # per ``HARDWARE_CLOCK_CACHE_S`` so co-tenant readers (turnout 132 # monitor, AIU poller) keep their airtime. 133 if _cache_is_fresh(self.ctx): 134 return _project(clock) 135 try: 136 state = await conn.read_hardware_clock() 137 except (TimeoutError, OSError) as e: 138 _log.warning("hardware clock read failed, using bean: %s", e) 139 else: 140 _mirror_to_bean(clock, state) 141 _record_cache(self.ctx) 142 return _project_hardware(state) 143 return _project(clock)
145 async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]: 146 data = data or {} 147 clock = _get_or_create_clock(self.ctx) 148 conn = _hardware_clock(self.ctx) 149 150 # Apply each field if present. On hardware-backed connections, 151 # write through to the CS *first* — the software bean cache is 152 # then refreshed from a follow-up read. Hardware writes can time 153 # out under serial-link contention; surface those as a 504-ish 154 # JsonServiceError instead of a bare 500 so the UI can show a 155 # graceful message. 156 if "rate" in data: 157 try: 158 rate = float(data["rate"]) 159 except (TypeError, ValueError): 160 raise JsonServiceError(400, "rate must be numeric") from None 161 if conn is not None: 162 try: 163 await conn.set_hardware_clock_rate(rate) 164 except TimeoutError: 165 raise JsonServiceError( 166 504, "command station did not acknowledge the rate change" 167 ) from None 168 try: 169 await clock.set_rate(rate) 170 except ValueError as e: 171 raise JsonServiceError(400, str(e)) from None 172 173 if "time" in data: 174 value = data["time"] 175 if not isinstance(value, str): 176 raise JsonServiceError(400, "time must be 'HH:MM' string") 177 hours, minutes = _parse_hhmm(value) 178 if conn is not None: 179 try: 180 await conn.set_hardware_clock_time(hours, minutes, 0) 181 except TimeoutError: 182 raise JsonServiceError( 183 504, "command station did not acknowledge the time change" 184 ) from None 185 try: 186 await clock.set_time(hours, minutes) 187 except ValueError as e: 188 raise JsonServiceError(400, str(e)) from None 189 190 if "running" in data: 191 run = bool(data["running"]) 192 if conn is not None: 193 try: 194 if run: 195 await conn.start_hardware_clock() 196 else: 197 await conn.stop_hardware_clock() 198 except TimeoutError: 199 raise JsonServiceError( 200 504, "command station did not acknowledge start/stop" 201 ) from None 202 if run: 203 await clock.start() 204 else: 205 await clock.stop() 206 207 # Re-project from the CS if we have one so the caller sees the 208 # authoritative state, not the (possibly stale) bean. A write 209 # always invalidates the cache — the caller needs the ground 210 # truth to know its update took effect. 211 if conn is not None: 212 _invalidate_cache(self.ctx) 213 try: 214 state = await conn.read_hardware_clock() 215 except (TimeoutError, OSError) as e: 216 _log.warning("hardware clock read after write failed: %s", e) 217 else: 218 _mirror_to_bean(clock, state) 219 _record_cache(self.ctx) 220 return _project_hardware(state) 221 return _project(clock)