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)
HARDWARE_CLOCK_CACHE_S = 5.0
class TimeService(jmri_server.service.JsonService):
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.

topic = 'time'
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
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)
async def on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
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)