`ruff format .` across the repo: 153 files, no behaviour change. This is the second gate CONTRIBUTING.md promises, and until now it reported 154 files needing reformat — so a first contributor ran the command the guide told them to run and could not tell their own diff from the background. Landed on its own, with nothing else in it, because a whole-repo reformat is only safe to skip past in blame if it is provably nothing but layout. It was checked that way rather than assumed: every changed file was formatted in memory and compared by full `ast.dump()`, and 152 of 153 come out structurally identical (the one exception was a docstring opening on a quote character, fixed in the commit before this one). The string and comment token streams were diffed separately, so a quote flip or an edited comment could not hide inside a re-layout — five quote-style normalisations and sixteen implicit-concatenation joins, all value-identical, and one commented-out line gaining the space after its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite is 1888 green, gallery gate and `test_theme16` included. What actually changed, in order of how much of the diff it is: wrapped calls and signatures rejoin onto one line where they fit inside the hundred columns; the magic trailing comma expands what it is there to expand; complex slices gain their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are laid out to be read were fenced in the previous commit and are untouched here. The aligned trailing-comment column is gone from about ninety lanes that were not fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced where it sits. Every comment stays on the entry it describes, so nothing there reads wrong; it just no longer lines up. Add this commit to `.git-blame-ignore-revs`, which follows immediately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
123 lines
5.0 KiB
Python
123 lines
5.0 KiB
Python
"""The shared transmit clock: how long ago we last put something on the air.
|
|
|
|
MeshTerm has always paced its own bursts — a trace waits between samples, a TX sweep
|
|
between levels — but each did it privately, inside its own loop. Nothing knew what
|
|
*another* feature had just transmitted, so a trace could finish and an advert go out a
|
|
tenth of a second later, and no screen could say why it was making you wait.
|
|
|
|
This is that missing clock. Every real transmission marks it (see the send methods in
|
|
:mod:`meshterm.core.connection`, which are the only place the radio is actually spoken
|
|
to), and anything about to transmit can ask :func:`remaining` how long is left before it
|
|
politely may. The waiting itself is the UI's job — see
|
|
:func:`meshterm.ui.cooldown.wait_for_cooldown`, which turns a wait worth noticing into a
|
|
countdown you can back out of.
|
|
|
|
Two clocks, because two kinds of transmission cost the mesh very different amounts:
|
|
|
|
* **every 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 a node can ask for — every repeater in range rebroadcasts it —
|
|
so it waits out both clocks, and its own is floored well above the general one.
|
|
|
|
Timing is :func:`time.monotonic`, not the wall clock: this measures an elapsed interval,
|
|
and a clock sync mid-session (which MeshTerm can itself trigger) must not make a cooldown
|
|
appear to have elapsed, or to have years left.
|
|
|
|
Like :func:`meshterm.core.preferences.current`, the gate is a process-wide singleton
|
|
rather than something passed down: the layer that transmits and the layer that waits are
|
|
far apart, and threading a clock between them would touch every call in between for
|
|
nothing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
|
|
class TransmitGate:
|
|
"""The last-transmission clock, and the wait it implies.
|
|
|
|
:meth:`mark` records that something went out; :meth:`remaining` says how long a
|
|
caller should hold off. A fresh gate has transmitted nothing, so nothing is owed.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
"""Start an idle gate — nothing sent, nothing owed."""
|
|
self._last_sent: float | None = None
|
|
self._last_flood_advert: float | None = None
|
|
|
|
def mark(self, *, flood_advert: bool = False) -> None:
|
|
"""Record a transmission as having just finished.
|
|
|
|
Called *after* the send returns, so the cooldown counts from the moment the air
|
|
was free again rather than from when we started talking.
|
|
|
|
Args:
|
|
flood_advert: Whether the transmission was a flood advert, which starts its
|
|
own longer clock in addition to the general one.
|
|
"""
|
|
now = time.monotonic()
|
|
self._last_sent = now
|
|
if flood_advert:
|
|
self._last_flood_advert = now
|
|
|
|
def remaining(self, *, flood_advert: bool = False) -> float:
|
|
"""Seconds a caller should wait before transmitting; ``0.0`` when it may go now.
|
|
|
|
Args:
|
|
flood_advert: Whether the transmission being considered is a flood advert. A
|
|
flood advert waits out *both* clocks — the general cooldown since anything
|
|
was sent, and the flood cooldown since the last flood advert — so this
|
|
returns whichever is longer.
|
|
|
|
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 had ever been sent.
|
|
|
|
For a fresh session and for tests; there is no user-facing way to clear a cooldown,
|
|
which would rather defeat it.
|
|
"""
|
|
self._last_sent = None
|
|
self._last_flood_advert = None
|
|
|
|
|
|
def _left(last: float | None, cooldown: float) -> float:
|
|
"""Seconds left of ``cooldown`` since ``last``, or ``0.0`` if it never happened."""
|
|
if last is None or cooldown <= 0:
|
|
return 0.0
|
|
return max(0.0, cooldown - (time.monotonic() - last))
|
|
|
|
|
|
#: The clock for this process. A module-level singleton for the reason given in the module
|
|
#: docstring: the transmitting layer and the waiting layer never meet.
|
|
_gate = TransmitGate()
|
|
|
|
|
|
def current() -> TransmitGate:
|
|
"""The transmit clock in force for this process."""
|
|
return _gate
|
|
|
|
|
|
def mark(*, flood_advert: bool = False) -> None:
|
|
"""Record a transmission on the process-wide clock (see :meth:`TransmitGate.mark`)."""
|
|
_gate.mark(flood_advert=flood_advert)
|
|
|
|
|
|
def remaining(*, flood_advert: bool = False) -> float:
|
|
"""The wait owed on the process-wide clock (see :meth:`TransmitGate.remaining`)."""
|
|
return _gate.remaining(flood_advert=flood_advert)
|
|
|
|
|
|
__all__ = ["TransmitGate", "current", "mark", "remaining"]
|