Files
Jean-Pierre MartineauandClaude Opus 5.5 5fc224476c Docstrings and comments of meshterm/services are in ASD-STE100
The same rewrite as for the core package. Three comments now agree with
the code: the activity bucket of the header is one minute
(ACTIVITY_BUCKET_S), the RX log holds packets, and a reference to a
module note that does not exist is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 03:48:44 -04:00

216 lines
9.4 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Set the clock of the companion from this computer: by hand from Device config, or on connect.
Most MeshCore boards have no real-time clock. A power cycle sets them back to the epoch
that the firmware boots with. Then each message that they send has a timestamp in a year
that the mesh does not agree with. :func:`set_clock` is the one step on the device side
that corrects it. The function reads the time that the device has, writes the time of this
computer, and returns the drift that it corrected. The drift is the fact to keep, and a
successful set destroys it. The *Sync clock* action of the Device config screen (``config
sync-clock`` on the command line) calls this function, and shows an acknowledgement on the
screen. :class:`ClockSync` calls the same function itself, one time for each connection,
when the ``set_clock_on_connect`` preference asks for it. That preference is off by
default, because a write to a device that nobody asked for is a choice of the owner.
**Forward only.** MeshCore firmware never sets its clock back. It refuses a time that is
earlier than the time that it has (:class:`~meshterm.core.connection.ClockAheadError`). The
clock of a device can be ahead of the clock of this computer, because of a GPS fix,
another app, or drift. Then MeshTerm reads the clock first, and does not write it at all.
If the clock is only a few seconds ahead, it is in sync. If it is more, MeshTerm tells this
fact clearly, with the thing that resets it. MeshTerm does not try again at each connect,
because the firmware refuses each try with an error code that it calls "malformed".
The automatic set is a background step, not a startup step. The connect path gives the
device to the app and continues. The write occurs in its own task, so a slow or silent
firmware never blocks the menu. The set reports in the **log**, never on the screen.
The set occurs one time for each *connection*. The identity of the device object
identifies the connection, as in the key probe of the advert scheduler. A reconnect after
a lost link is a new :class:`~meshterm.core.connection.Device`, and it gets its own set.
A lost link is exactly the time when the board may have rebooted and lost its clock again.
The interactive session starts the service. Scripted CLI runs never start it. Thus a
one-shot command cannot change a device that it was asked only 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 the clock of a device can be, and still count as in sync. Two clocks with
#: a difference of a few seconds are not a problem to report: for example, a device with a
#: GPS-disciplined clock next to a PC that is approximately one second off the true time.
#: Also, the firmware cannot remove that difference.
IN_SYNC_AHEAD_S = 60
@dataclass(frozen=True, slots=True)
class ClockSet:
"""The result of one clock set.
Attributes:
epoch: The UNIX time written to the device. If nothing was written, the time that
the device already had.
drift_s: The difference between the device clock and our clock before the set
(device minus host, in seconds). ``None`` when the firmware did not report its
clock.
written: Whether the clock was written. ``False`` when the clock was already a few
seconds ahead (in the limit of :data:`IN_SYNC_AHEAD_S`). The firmware refuses
to set such a clock back, and the clock does not need a set.
"""
epoch: int
drift_s: int | None
written: bool = True
@property
def set_at(self) -> datetime:
"""The written instant, in the local time of this computer."""
return datetime.fromtimestamp(self.epoch).astimezone()
@property
def stamp(self) -> str:
"""The written instant, in the format that the acknowledgement and the log print."""
return self.set_at.strftime("%Y-%m-%d %H:%M:%S")
async def set_clock(device: Device) -> ClockSet:
"""Set the clock of ``device`` to the time of this computer, and return the correction.
The read of the device clock before the write is optional. The drift is good to have,
but it is not necessary. A firmware that does not answer still gets its clock set.
Args:
device: A connected device.
Returns:
The instant that was written, and the drift that it corrected.
Raises:
ClockAheadError: If the device clock is more than :data:`IN_SYNC_AHEAD_S` ahead of
the clock of this computer (from the first read), or if the firmware refused
the write because it sets the clock back. In the margin, nothing is written,
and the result tells this (``written`` false).
All the errors that the device raises when the write fails for a different reason.
"""
try:
before = await device.get_time()
except Exception: # noqa: BLE001 - an optional read. The drift is only extra information.
before = None
epoch = int(time.time())
if before is not None and before > epoch:
# The firmware only moves its clock forward, so it can only refuse a write here. A
# small difference is in sync. A larger difference is a fact to report, not a write
# to try again.
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 clock set on connect for an interactive session.
Use :meth:`start`, :meth:`stop`/:meth:`aclose`, and :meth:`on_connected`. The context
calls :meth:`on_connected` when each connection is stable.
"""
def __init__(self, ctx: AppContext) -> None:
"""Make an idle service for an application context.
Args:
ctx: The shared application context. The service reads the preference from it
at each connection, and the logger. The service changes nothing until
:meth:`start`.
"""
self._ctx = ctx
self._armed = False
self._task: asyncio.Task | None = None
# The identity of the last device object that was set. Thus one connection gets
# one set, also when 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, and set the clock immediately if a device is already connected.
Idempotent. The startup picker can adopt a live connection before a service
starts. Thus the connection that already exists counts as the first connection.
"""
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, and cancel a set that is still in progress. 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 the end of the session (an alias for :meth:`stop`)."""
await self.stop()
def on_connected(self, device: Device) -> None:
"""Note a stable connection, and set its clock in the background if the preference asks.
Returns immediately, because the set runs in its own task. This method does nothing
while the service is not started (scripted runs), while the preference is off, and
for a device that this session has already set.
Args:
device: The device whose connection the context completed a moment ago.
"""
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 write the result to 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. We must not break the link.
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)