Files
Jean-Pierre MartineauandClaude Opus 5.5 ffe31dac36 Docstrings and comments of meshterm/core are in ASD-STE100
The prose of each docstring and comment in the core package now follows
ASD-STE100 Simplified Technical English, with Canadian spelling: short
sentences, simple verb tenses, no dashes or semicolons, and one word for
one concept (docs/development/writing-style.md has the rules and the
glossary). The meaning and each reason stay. The code does not change:
the AST of each file, without its docstrings, is the same as before.

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

137 lines
5.7 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The shared transmit clock: how long ago our node last transmitted.
MeshTerm always paced its own bursts: a trace waits between samples, and a TX sweep waits
between levels. But each feature did it privately, inside its own loop. No feature knew
what another feature transmitted a moment before. Thus a trace could finish and an advert
could go out a tenth of a second later, and no screen could tell why it made you wait.
This module is that missing clock. Each real transmission marks it (refer to the send
methods in :mod:`meshterm.core.connection`, which are the only place that speaks to the
radio). Code that is about to transmit can ask :func:`remaining` how long it must wait to
transmit politely. The wait itself is the job of the UI. Refer to
:func:`meshterm.ui.cooldown.wait_for_cooldown`, which changes a wait that is long enough
to notice into a countdown that you can cancel.
There are two clocks, because two types of transmission cost the mesh very different
amounts:
* **Each transmission**, spaced by the ``trace_cooldown_s`` preference. This is duty-cycle
courtesy: our own airtime, spread out.
* **Flood adverts**, spaced by the longer ``flood_advert_cooldown_s``. A flood advert is
the most expensive thing that a node can ask for, because each repeater in range relays
it. Thus a flood advert waits for both clocks, and the minimum of its own clock is much
higher than the minimum of the general clock.
The timing uses :func:`time.monotonic`, not the wall clock, because this module measures
an elapsed interval. A clock sync during a session (which MeshTerm itself can start) must
not make a cooldown look finished, or look as if it has years left.
The gate is a singleton for the full process, the same as
:func:`meshterm.core.preferences.current`. Code does not pass it down. The reason: the
layer that transmits and the layer that waits are far apart. To pass a clock down between
them changes each call between them, and gives no benefit.
"""
from __future__ import annotations
import time
class TransmitGate:
"""The clock of the last transmission, and the wait that it causes.
:meth:`mark` stores that something went out. :meth:`remaining` tells how long a
caller must wait. A new gate transmitted nothing, so no wait is necessary.
"""
def __init__(self) -> None:
"""Start an idle gate: nothing sent, and no wait."""
self._last_sent: float | None = None
self._last_flood_advert: float | None = None
def mark(self, *, flood_advert: bool = False) -> None:
"""Store a transmission that finished at this moment.
MeshTerm calls this method after the send returns. Thus the cooldown counts from
the moment when the air was free again, not from when our node started to
transmit.
Args:
flood_advert: Whether the transmission was a flood advert. A flood advert
starts its own longer clock, in addition to the general clock.
"""
now = time.monotonic()
self._last_sent = now
if flood_advert:
self._last_flood_advert = now
@property
def last_sent(self) -> float | None:
"""When something last went out, on the :func:`time.monotonic` clock (or ``None``).
This is for a listener that reads our own transmissions as an end of the silence
on the air (the weekly advert waits for a quiet period), not as a cooldown to wait
for. ``None`` means that nothing ever went out.
"""
return self._last_sent
def remaining(self, *, flood_advert: bool = False) -> float:
"""The seconds that a caller must wait before it transmits (``0.0`` to go now).
Args:
flood_advert: Whether the planned transmission is a flood advert. A flood
advert waits for both clocks: the general cooldown since the last
transmission, and the flood cooldown since the last flood advert. Thus
the method returns the longer of the two waits.
Returns:
The wait in seconds, never negative.
"""
from .preferences import current as current_preferences
preferences = current_preferences()
wait = _left(self._last_sent, preferences.trace_cooldown_s)
if flood_advert:
wait = max(wait, _left(self._last_flood_advert, preferences.flood_advert_cooldown_s))
return wait
def reset(self) -> None:
"""Forget both clocks, as if nothing was ever sent.
This is for a new session and for tests. The user has no way to clear a cooldown,
because such a way makes the cooldown useless.
"""
self._last_sent = None
self._last_flood_advert = None
def _left(last: float | None, cooldown: float) -> float:
"""The seconds of ``cooldown`` that remain after ``last``, or ``0.0`` if it never occurred."""
if last is None or cooldown <= 0:
return 0.0
return max(0.0, cooldown - (time.monotonic() - last))
#: The clock for this process. It is a module-level singleton, for the reason that the
#: module docstring gives: the layer that transmits and the layer that waits never meet.
_gate = TransmitGate()
def current() -> TransmitGate:
"""The transmit clock that applies to this process."""
return _gate
def mark(*, flood_advert: bool = False) -> None:
"""Store a transmission on the clock of the process (refer to :meth:`TransmitGate.mark`)."""
_gate.mark(flood_advert=flood_advert)
def remaining(*, flood_advert: bool = False) -> float:
"""The necessary wait on the process clock (refer to :meth:`TransmitGate.remaining`)."""
return _gate.remaining(flood_advert=flood_advert)
__all__ = ["TransmitGate", "current", "mark", "remaining"]