Files
MeshTerm/meshterm/services/clock_sync.py
T
Jean-Pierre MartineauandClaude Opus 5.5 79137598cf Clock sync explains a radio whose clock is ahead instead of calling it malformed
MeshCore firmware sets its clock only forward: CMD_SET_DEVICE_TIME with an earlier time is answered ERR_CODE_ILLEGAL_ARG, which read as 'the device rejected the request as malformed'. Since 2026-09-24 the companion's clock has been ahead of this computer's (itself within 0.7 s of NTP), so every connect's sync was refused. set_clock now reads the radio's clock first: within a minute ahead is in sync and nothing is written; further ahead raises ClockAheadError, which says by how much and that a reboot resets it. The device layer also maps the firmware's refusal to that error, and the simulator's clock now only moves forward, like the firmware's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:10:09 -04:00

209 lines
8.8 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Set the companion's clock from this computer — by hand from Device config, or on connect.
Most MeshCore boards carry no real-time clock: a power cycle puts them back at whatever
epoch the firmware boots with, and every message they then send is stamped in a year the
mesh does not agree on. :func:`set_clock` is the one device-side step that corrects it —
read what the radio thinks the time is, write ours, and hand back the drift that was
corrected, the fact worth keeping and the one the set destroys by succeeding. The Device
config screen's *Sync clock* action (``config sync-clock`` on the command line) calls it
and acknowledges on screen; :class:`ClockSync` calls the same function on its own, once
per connection, when the ``set_clock_on_connect`` preference asks for it (off by default —
writing to a radio that nobody asked to be written to is a choice the owner makes).
**Forward only.** MeshCore firmware never sets its clock back: a time earlier than the one
it holds is refused (:class:`~meshterm.core.connection.ClockAheadError`). So a radio whose
clock has run ahead of this computer's — a GPS fix, another app, drift — is read first and
not written at all: a few seconds ahead is simply in sync, and more is stated plainly with
what resets it, rather than retried every connect into a refusal the firmware's error code
calls "malformed".
The automatic set is a background step, not a startup step: the connect path hands the
device over and carries on, and the write happens in a task of its own so a slow or silent
firmware never holds the menu. It reports in the **log**, never on screen.
Once per *connection*, keyed by the device object's identity like the advert scheduler's
key probe: a reconnect after a link drop is a fresh :class:`~meshterm.core.connection.Device`
and gets its own set, because a drop is exactly when the board may have rebooted and lost
its clock again. The interactive session starts the service; scripted CLI runs never do,
so a one-shot command cannot change a radio it was only asked to read.
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING
from ..core.connection import ClockAheadError
if TYPE_CHECKING:
from ..context import AppContext
from ..core.connection import Device
#: How far ahead a radio's clock may run and still count as in sync. Two clocks a few
#: seconds apart — a GPS-disciplined radio beside a PC that is itself a second or so off
#: true time — are not a problem to report, and the firmware couldn't close the gap anyway.
IN_SYNC_AHEAD_S = 60
@dataclass(frozen=True, slots=True)
class ClockSet:
"""What one clock set did.
Attributes:
epoch: The UNIX time written to the device — or, where nothing was written, the
time the device already held.
drift_s: How far the device's clock was from ours *before* the set (device minus
host, seconds), or ``None`` where the firmware would not report its clock.
written: Whether the clock was written. ``False`` when it was already a few
seconds ahead (within :data:`IN_SYNC_AHEAD_S`), which firmware would refuse to
set back and which needs no setting.
"""
epoch: int
drift_s: int | None
written: bool = True
@property
def set_at(self) -> datetime:
"""The written instant, in this computer's local time."""
return datetime.fromtimestamp(self.epoch).astimezone()
@property
def stamp(self) -> str:
"""The written instant as the acknowledgement and the log print it."""
return self.set_at.strftime("%Y-%m-%d %H:%M:%S")
async def set_clock(device: Device) -> ClockSet:
"""Set ``device``'s clock to this computer's, returning what was corrected.
The read of the device's own clock before the write is optional — the drift is a
nicety, and a firmware that will not answer still gets its clock set.
Args:
device: A connected device.
Returns:
The instant written and the drift it corrected.
Raises:
ClockAheadError: If the device's clock is ahead of this computer's by more than
:data:`IN_SYNC_AHEAD_S` (read first) or the firmware refused the write as one
that would set it back. Within the margin nothing is written, and the result
says so (``written`` false).
Whatever the device raises when the write itself fails otherwise.
"""
try:
before = await device.get_time()
except Exception: # noqa: BLE001 - optional read; the drift is a nicety
before = None
epoch = int(time.time())
if before is not None and before > epoch:
# Firmware only moves its clock forward, so a write here could only be refused.
# Close enough is in sync; further ahead is a fact to state, not a write to retry.
if before - epoch > IN_SYNC_AHEAD_S:
raise ClockAheadError(before - epoch)
return ClockSet(epoch=before, drift_s=before - epoch, written=False)
await device.set_time(epoch)
return ClockSet(epoch=epoch, drift_s=None if before is None else before - epoch)
class ClockSync:
"""Owns the on-connect clock set for an interactive session.
Interact through :meth:`start`, :meth:`stop`/:meth:`aclose`, and
:meth:`on_connected`, which the context calls as each connection settles.
"""
def __init__(self, ctx: AppContext) -> None:
"""Initialize an idle service bound to an application context.
Args:
ctx: The shared application context, read for the preference at each
connection and for the logger. Nothing is touched until :meth:`start`.
"""
self._ctx = ctx
self._armed = False
self._task: asyncio.Task | None = None
# Identity of the device object last set, so one connection is set once even if
# the connect path reports it more than once.
self._synced: int | None = None
@property
def active(self) -> bool:
"""Whether the service is armed to act on connections."""
return self._armed
async def start(self) -> None:
"""Arm the service, setting the clock at once if a device is already connected.
Idempotent. The startup picker may have adopted a live connection before any
service started, so the connection that already exists counts as the first one.
"""
if self._armed:
return
self._armed = True
device = self._ctx._device
if device is not None:
self.on_connected(device)
async def stop(self) -> None:
"""Disarm, cancelling a set still in flight. Idempotent."""
self._armed = False
task, self._task = self._task, None
if task is not None and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def aclose(self) -> None:
"""Stop at session end (an alias for :meth:`stop`)."""
await self.stop()
def on_connected(self, device: Device) -> None:
"""Note a settled connection and, if the preference asks, set its clock in the background.
Returns at once; the set runs in its own task. A no-op while the service is not
started (scripted runs), while the preference is off, and for a device this
session has already set.
Args:
device: The device the context just finished connecting.
"""
if not self._armed or not self._ctx.preferences.set_clock_on_connect:
return
if self._synced == id(device):
return
self._synced = id(device)
self._task = asyncio.ensure_future(self._sync(device))
async def _sync(self, device: Device) -> None:
"""Run :func:`set_clock` and put the outcome in the log."""
log = self._ctx.log
try:
done = await set_clock(device)
except asyncio.CancelledError:
raise
except ClockAheadError as exc:
log.warning("clock sync: left the device clock alone: %s", exc)
return
except Exception as exc: # noqa: BLE001 - best-effort; the link is not ours to break
log.warning("clock sync: could not set the device clock: %s", exc)
return
if not done.written:
log.info(
"clock sync: device clock already in sync (%+d s, ahead of ours; firmware "
"never sets it back)",
done.drift_s,
)
elif done.drift_s is None:
log.info("clock sync: device clock set to %s (drift unknown)", done.stamp)
else:
log.info("clock sync: device clock set to %s (was %+d s off)", done.stamp, done.drift_s)