jmri_server.clock_sync

Periodic hardware-clock sync task.

Reads connection.read_hardware_clock() on a fixed cadence and mirrors the result into the software ~jmri_core.beans.fast_clock.FastClock bean. The bean's state_changed signal fires on every mirror write, which the WiThrottle server picks up and rebroadcasts as PFT — so a physical throttle running on the LocoNet bus and a WiThrottle client end up converged on the CS clock rather than drifting apart.

Attached in build_app()'s lifespan; skipped silently when the active connection doesn't expose read_hardware_clock (Internal, DCC-EX). Poll cadence is deliberately loose (30 s) to keep serial bandwidth free for the turnout monitor + AIU poller — a 30 s drift window is invisible to anyone watching a fast clock.

  1"""Periodic hardware-clock sync task.
  2
  3Reads ``connection.read_hardware_clock()`` on a fixed cadence and
  4mirrors the result into the software :class:`~jmri_core.beans.fast_clock.FastClock`
  5bean. The bean's ``state_changed`` signal fires on every mirror
  6write, which the WiThrottle server picks up and rebroadcasts as
  7PFT — so a physical throttle running on the LocoNet bus and a
  8WiThrottle client end up converged on the CS clock rather than
  9drifting apart.
 10
 11Attached in :func:`build_app`'s lifespan; skipped silently when the
 12active connection doesn't expose ``read_hardware_clock`` (Internal,
 13DCC-EX). Poll cadence is deliberately loose (30 s) to keep serial
 14bandwidth free for the turnout monitor + AIU poller — a 30 s drift
 15window is invisible to anyone watching a fast clock.
 16"""
 17from __future__ import annotations
 18
 19import asyncio
 20import contextlib
 21import logging
 22import time
 23from typing import Any
 24
 25from jmri_core.beans.fast_clock import FastClock, HardwareClockState
 26
 27_log = logging.getLogger(__name__)
 28
 29# Cadence. Long enough that the sync task never fights with the
 30# turnout monitor for bandwidth; short enough that fast-clock drift
 31# stays below a minute of face-value time.
 32_SYNC_INTERVAL_S = 30.0
 33
 34# Grace after the connection opens before the first read attempt.
 35# NCE traffic controllers reject commands until the reachability
 36# monitor confirms the CS is up (~3 s post-open); LocoNet needs a
 37# similar window for the first inbound heartbeat.
 38_STARTUP_GRACE_S = 4.0
 39
 40
 41def _mirror_into_bean(bean: FastClock, state: HardwareClockState) -> None:
 42    """Update the software bean's anchor + rate + running flag from a
 43    hardware read. Fires ``state_changed`` — same pattern
 44    :func:`jmri_server.services.fast_clock._mirror_to_bean` uses on
 45    the demand-read path."""
 46    bean.rate = state.rate
 47    bean.running = state.running
 48    bean._base_seconds = (      # noqa: SLF001 — internal cache update
 49        state.hours * 3600 + state.minutes * 60 + state.seconds
 50    )
 51    bean._real_anchor = time.monotonic()   # noqa: SLF001
 52
 53
 54async def _sync_loop(
 55    ctx: Any,
 56    *,
 57    interval_s: float = _SYNC_INTERVAL_S,
 58    grace_s: float = _STARTUP_GRACE_S,
 59) -> None:
 60    """Background loop — runs until the task is cancelled.
 61
 62    Reads the hardware clock every *interval_s* seconds and mirrors
 63    the result into the ``FastClock`` bean on the context. Failures
 64    are logged at DEBUG and the loop continues; a persistently dark
 65    CS just means the software bean advances on its own until the CS
 66    comes back.
 67    """
 68    try:
 69        await asyncio.sleep(grace_s)
 70    except asyncio.CancelledError:
 71        return
 72    while True:
 73        try:
 74            await asyncio.sleep(interval_s)
 75        except asyncio.CancelledError:
 76            return
 77        conn = getattr(ctx, "connection", None)
 78        if conn is None or not getattr(conn, "is_open", False):
 79            continue
 80        reader = getattr(conn, "read_hardware_clock", None)
 81        if reader is None:
 82            # Nothing to sync — connection doesn't have a hardware
 83            # clock (Internal, DCC-EX). Exit; there's no point
 84            # burning wakeups.
 85            return
 86        try:
 87            state: HardwareClockState = await reader()
 88        except (TimeoutError, OSError, RuntimeError) as e:
 89            _log.debug("clock sync: hardware read failed (%s)", e)
 90            continue
 91        bean = getattr(ctx, "fast_clock", None)
 92        if bean is None:
 93            bean = FastClock()
 94            ctx.fast_clock = bean       # type: ignore[attr-defined]
 95        _mirror_into_bean(bean, state)
 96        try:
 97            await bean._notify()        # noqa: SLF001 — fire state_changed
 98        except Exception:  # noqa: BLE001
 99            _log.exception("clock sync: FastClock notify failed")
100
101
102def start_sync_task(ctx: Any) -> asyncio.Task[None] | None:
103    """Kick off the periodic sync loop. Returns the task so the
104    caller can cancel it on shutdown, or ``None`` if the active
105    connection has no hardware clock."""
106    conn = getattr(ctx, "connection", None)
107    if conn is None or not hasattr(conn, "read_hardware_clock"):
108        return None
109    task = asyncio.create_task(_sync_loop(ctx), name="fast-clock-sync")
110    _log.info("fast-clock sync task started (%.0fs cadence)", _SYNC_INTERVAL_S)
111    return task
112
113
114async def stop_sync_task(task: asyncio.Task[None] | None) -> None:
115    if task is None or task.done():
116        return
117    task.cancel()
118    with contextlib.suppress(asyncio.CancelledError, Exception):
119        await task
def start_sync_task(ctx: Any) -> _asyncio.Task[None] | None:
103def start_sync_task(ctx: Any) -> asyncio.Task[None] | None:
104    """Kick off the periodic sync loop. Returns the task so the
105    caller can cancel it on shutdown, or ``None`` if the active
106    connection has no hardware clock."""
107    conn = getattr(ctx, "connection", None)
108    if conn is None or not hasattr(conn, "read_hardware_clock"):
109        return None
110    task = asyncio.create_task(_sync_loop(ctx), name="fast-clock-sync")
111    _log.info("fast-clock sync task started (%.0fs cadence)", _SYNC_INTERVAL_S)
112    return task

Kick off the periodic sync loop. Returns the task so the caller can cancel it on shutdown, or None if the active connection has no hardware clock.

async def stop_sync_task(task: _asyncio.Task[None] | None) -> None:
115async def stop_sync_task(task: asyncio.Task[None] | None) -> None:
116    if task is None or task.done():
117        return
118    task.cancel()
119    with contextlib.suppress(asyncio.CancelledError, Exception):
120        await task