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
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.