Files
MeshTerm/meshterm/core/transmit_gate.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`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>
2026-09-07 01:07:19 -04:00

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"]