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>
216 lines
9.4 KiB
Python
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)
|