Files
MeshTerm/meshterm/ui/timemachine_screen.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00

1412 lines
60 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""The Time Machine: the mesh's recorded history, explorable node by node.
The interactive face of the ``timemachine`` tool. The database has been recording every
overheard packet since the first session, but nothing surfaced that history beyond the
dashboard's two-hour window — this screen is the archaeology dig. A picker offers the
whole mesh or any node ever heard; each subject renders as a scrollable page of braille
charts and stats over a switchable window (``w`` cycles 24 h → 7 d → 30 d → all time —
the PicoCalc's ring stops at 30 d, its F3 chip cycling the same three spans):
* a **node** shows its reception volume over the window, its median-SNR band (coloured
by quality), its hour-of-day rhythm (when does this node talk?), and the roll-up
stats — first/last heard, medians, extremes;
* the **whole mesh** shows packets per day and nodes per day across the history, the
mesh-wide hour-of-day rhythm, the arrivals of the window (nodes heard for the first
time ever, in aligned name/hash/first-heard lanes), and the all-time totals.
Charts read chronologically — oldest at the left, now at the right, the app-wide
timeline direction — and draw through :mod:`~meshterm.ui.braillechart`, so the grey
baseline always marks zero: the SNR band's readings hang below it or rise above it
by their actual sign. Everything is stored data — no device is needed and nothing
transmits.
"""
from __future__ import annotations
from collections.abc import Callable
from datetime import date, datetime, timedelta
from statistics import median
from typing import TYPE_CHECKING
from rich.console import Group, RenderableType
from rich.text import Text
from ..core.models import utcnow
from ..platforms import Platform, on_platform
from .braillechart import (
_TICK_GAP,
GAP,
axis_chart,
axis_chrome,
axis_label_w,
chart_span,
timeline_rows,
)
from .contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING, ContactListScreen, ContactRow
from .menus import command_label, fit_cells, section_heading
from .theme import name_style, snr_style
from .tui.render import render_lines
from .tui.screen import CANCEL, Screen
from .tui.select import Choice, Separator
from .widgets import (
ContactsSort,
_recency_style,
age_seconds,
body_heading,
format_ago,
highlighted_hash,
)
if TYPE_CHECKING:
from ..context import AppContext
from ..core.models import HeardNode
from ..services.trace_runner import NodeResolver
#: Every history window the screen knows (``None`` = everything ever recorded). What a
#: session actually offers is the platform-bound :data:`_WINDOWS` below.
_ALL_WINDOWS: tuple[tuple[str, timedelta | None], ...] = (
("24 h", timedelta(days=1)),
("7 d", timedelta(days=7)),
("30 d", timedelta(days=30)),
("all time", None),
)
#: The offered windows. The PicoCalc's ring stops at 30 d (JP, 2026-08-08): all time —
#: the one span whose scan grows with the whole history — stays a desktop affordance,
#: and the F3 chip there cycles the three spans that remain. Bound at platform-switch
#: time.
_WINDOWS: tuple[tuple[str, timedelta | None], ...] = _ALL_WINDOWS
@on_platform
def _bind_windows(platform: Platform) -> None:
"""Bind the offered window ring to the platform (runs now and on every switch)."""
global _WINDOWS
_WINDOWS = _ALL_WINDOWS[:3] if platform.footer_fkeys else _ALL_WINDOWS
#: Picker sentinel for the whole-mesh overview page.
MESH = ("mesh",)
#: Picker sentinel for our own node's page (the outbound ledger; see :func:`_self_sections`).
SELF = ("self",)
#: How many braille rows tall the volume/rhythm charts draw.
_CHART_ROWS = 2
#: How many braille rows tall the SNR band draws — one more than the volume charts,
#: because zero-anchoring (readings hang below the grey zero line by their actual
#: depth) spends some dots on honesty and the extra row buys the swing back.
_SNR_ROWS = 3
def bucketize(stamps: list[datetime], start: datetime, end: datetime, buckets: int) -> list[int]:
"""Fold timestamps into ``buckets`` equal slices of ``[start, end]``, oldest first."""
counts = [0] * max(1, buckets)
span = max(1.0, (end - start).total_seconds())
for stamp in stamps:
index = int((stamp - start).total_seconds() / span * buckets)
counts[min(buckets - 1, max(0, index))] += 1
return counts
#: The Rhythm chart's candidate slice widths in minutes, finest first: the chart takes
#: the finest whose full day fits the width (JP, 2026-08-09). A fixed 15-minute slicing
#: used to outrun the PicoCalc's 53 columns; now the standard desktop keeps its 15-minute
#: sweep (48 cells + both gutters), the console steps down only as far as its width
#: forces, and a terminal wide enough earns the finer sweeps. If even the hour chart is
#: too wide, it draws anyway — tough shit, per the spec.
_RHYTHM_SLICES: tuple[int, ...] = (1, 5, 10, 15, 20, 30, 60)
def _slice_note(minutes: int) -> str:
"""The heading atom naming the rhythm's slice width: ``20-min slices``, ``1 h slices``."""
return "1 h slices" if minutes >= 60 else f"{minutes}-min slices"
def _rhythm_slots(stamps: list[datetime], minutes: int) -> list[int]:
"""Fold timestamps into their local time-of-day slice at ``minutes`` per slot."""
slots = [0] * (24 * 60 // minutes)
for stamp in stamps:
local = stamp.astimezone()
slots[(local.hour * 60 + local.minute) // minutes] += 1
return slots
def _fold_slots(minute_grid: list[int], minutes: int) -> list[int]:
"""Fold the repository's minute-of-day base grid into ``minutes``-wide slices.
A minute divides every rung of :data:`_RHYTHM_SLICES`, so the mesh page re-slices the
one :meth:`~meshterm.persistence.repository.Repository.rhythm_activity` scan
client-side instead of re-querying per candidate width.
"""
per = max(1, minutes)
return [sum(minute_grid[i : i + per]) for i in range(0, len(minute_grid), per)]
def _fit_rhythm(
slots_at: Callable[[int], list[int]], width: int, base_w: int
) -> tuple[list[int], int, int]:
"""Pick the finest rhythm slice whose chart fits ``width``: ``(slots, minutes, label_w)``.
``base_w`` is the gutter width the section's other charts already need; the rhythm's
own scale joins it (a slice's tally can top a single volume bucket's), and the fit is
judged against the row a chart actually draws — both gutters as the platform draws
them (:func:`~meshterm.ui.braillechart.axis_chrome`) plus the cells. The coarsest
slice comes back even when it doesn't fit: the ladder has nowhere further to step,
and a clipped hour chart beats no rhythm at all.
"""
slots: list[int] = [0]
label_w = base_w
for minutes in _RHYTHM_SLICES:
slots = slots_at(minutes)
label_w = max(base_w, axis_label_w(max(slots), _CHART_ROWS))
if axis_chrome(label_w) + len(slots) // 2 <= width:
return slots, minutes, label_w
return slots, _RHYTHM_SLICES[-1], label_w
def bucket_medians(
pairs: list[tuple[datetime, float]], start: datetime, end: datetime, buckets: int
) -> list[float | None]:
"""Per-bucket medians of timestamped readings (``None`` for empty buckets)."""
grouped: list[list[float]] = [[] for _ in range(max(1, buckets))]
span = max(1.0, (end - start).total_seconds())
for stamp, value in pairs:
index = int((stamp - start).total_seconds() / span * buckets)
grouped[min(buckets - 1, max(0, index))].append(value)
return [median(values) if values else None for values in grouped]
def _snr_cell_style(values: list[float]) -> str:
"""Colour one SNR-band cell by its readings' quality (the shared SNR palette)."""
return snr_style(sum(values) / len(values))
def _time_axis(start: datetime, end: datetime) -> Callable[[float], str]:
"""A compact axis labeller over a real time span, closing on ``now``.
A window wider than a couple of days reads its marks as bare dates (``Jul 4``); a
tighter one, where the calendar day barely changes, reads them as times (``18:30``)
— the same "shorten what you can" the day charts' dates use, so the volume and SNR
axes stay legible instead of repeating a full ``Jul 04 18:30`` stamp at every tick.
"""
wide = (end - start).total_seconds() > 2 * 86400
def label_at(frac: float) -> str:
if frac >= 1.0:
return "now"
when = (start + (end - start) * frac).astimezone()
return f"{when:%b} {when.day}" if wide else f"{when:%H:%M}"
return label_at
def _quarter_axis(frac: float) -> str:
"""The rhythm charts' labeller: the local hour at ``frac`` of a full-day sweep.
Whatever slice width the ladder settled on, the slices span midnight to midnight, so
the fraction maps onto the whole ``0 → 24 h`` day (the right edge closing on
``24 h``), landing the intermediate marks on clean six-hour boundaries.
"""
return f"{round(frac * 24)} h"
class TimeMachineScreen(Screen):
"""One subject's history page: scrollable sections, ``w`` cycles the window."""
floating = False
footer_hint = "↑↓ PgUp/PgDn scroll · w window · Esc back"
@property
def fkey_lane(self):
"""The shared pager, plus the window cycle on F3.
``w`` is the whole point of this screen — the same history at several spans — and
it is exactly the kind of affordance that vanishes on a platform with no hint line
to read it off. The chip is the only place the PicoCalc can learn the key exists,
so it earns the free F3 slot even though the letter itself is easy to press. (One
chip per span on F1–F3 was tried and reverted — JP, 2026-08-09: the cycle was
better.)
The chip names the span it would *take you to*, not the one on screen: the title
already says where you are, so a chip repeating it would be the same claim twice
and would never tell you what pressing it does. ``all time`` shortens to ``all``
to stay inside the 6-cell chip — the only span whose name doesn't already fit,
and one this platform's ring may not even offer (see :func:`_bind_windows`).
"""
from .tui.fkeys import FPair, default_lane
lane = list(default_lane(nav=self.content_overflows))
nxt, _delta = _WINDOWS[(self._window_index + 1) % len(_WINDOWS)]
lane[2] = FPair(f"▸ {'all' if nxt == 'all time' else nxt}", "window")
return lane
def __init__(
self,
*,
session, # noqa: ANN001 - TuiSession, imported lazily to avoid a cycle
label: str,
build: Callable[[timedelta | None, int], list[RenderableType]],
) -> None:
"""Create the page over its section builder.
Args:
session: The running TUI session (for repaints on window switch).
label: The subject's display name (titles the screen).
build: Renders the sections for ``(window, width)``; called once per
window/width combination and cached — the data is stored history,
so nothing needs re-querying per repaint.
"""
super().__init__()
self._session = session
self._label = label
self._build = build
self._window_index = 1 # open on 7 d: enough depth to see shape, still fast
self._cache: dict[tuple[int, int], list[str]] = {}
self._set_title()
def _set_title(self) -> None:
name, _delta = _WINDOWS[self._window_index]
self.title = f"{self._label} · {name}"
def handle(self, action: str, data: str = "") -> None:
"""Scroll, cycle the window, or dismiss.
The window cycles on either the ``w`` key or the ``window`` action the F-key lane
dispatches — one behaviour, two ways in, so the chip is not a second implementation
of the letter. The ring it cycles is the platform's (see :func:`_bind_windows`).
"""
if action == "up":
self.scroll_lines(-1)
elif action == "down":
self.scroll_lines(1)
elif action == "pageup":
self.scroll_pages(-1)
elif action in ("pagedown", "space"):
self.scroll_pages(1)
elif action in ("home", "ctrl_home"):
self.scroll_to_top()
elif action in ("end", "ctrl_end"):
self.scroll_to_bottom()
elif action == "window" or (action == "text" and data.lower() == "w"):
self._window_index = (self._window_index + 1) % len(_WINDOWS)
self._set_title()
self.scroll_to_top()
self._session.invalidate()
elif action == "escape":
self.resolve(None)
def render_body(self, width: int) -> list[str]:
"""Render (or reuse) the current window's sections."""
key = (self._window_index, width)
lines = self._cache.get(key)
if lines is None:
_name, delta = _WINDOWS[self._window_index]
lines = render_lines(Group(*self._build(delta, width)), width)
self._cache[key] = lines
self._scroll_total = max(1, len(lines))
return lines
# --- the node page -----------------------------------------------------------------------
def _when_label(when: datetime) -> str:
"""A compact local timestamp for chart captions: ``Jul 04 18:30``."""
return when.astimezone().strftime("%b %d %H:%M")
def _node_sections(
ctx: AppContext, node_id: str, label: str, window: timedelta | None, width: int
) -> list[RenderableType]:
"""Build one node's history page: volume, SNR band, rhythm, and the roll-up."""
now = utcnow()
since = now - window if window is not None else None
observations = ctx.repo.node_observations(node_id, since=since)
if not observations:
return [
Text(),
Text("Nothing recorded in this window.", style="muted"),
Text("Press w to widen it.", style="muted"),
]
start = since or observations[0].observed_at
stamps = [o.observed_at for o in observations]
snr_pairs = [(o.observed_at, float(o.snr)) for o in observations if o.snr is not None]
# Volume, SNR, and the rhythm share one y-axis gutter width (like the mesh page's
# charts) so their left edges line up. A provisional width finds the peaks that size
# the gutter, then the real width re-buckets Volume/SNR flush with it. The rhythm
# folds every reception into local time-of-day slices at the finest width the ladder
# fits (see :data:`_RHYTHM_SLICES`) — a slice's tally can top a single volume
# bucket's, so its peak joins the sizing too.
def _layout(label_w: int) -> tuple[int, int]:
# A chart row spends axis_chrome(label_w) beside its cells -- both gutters, as
# the platform draws them (the desktop mirrors its marks, the console doesn't).
chars = max(20, width - axis_chrome(label_w))
return chars, chars * 2
chars, buckets = _layout(1)
volume = bucketize(stamps, start, now, buckets)
lo, hi = chart_span(bucket_medians(snr_pairs, start, now, buckets)) if snr_pairs else (0.0, 0.0)
base_w = max(axis_label_w(max(volume), _CHART_ROWS), axis_label_w(hi, _SNR_ROWS, lo=lo))
slots, slice_minutes, label_w = _fit_rhythm(
lambda minutes: _rhythm_slots(stamps, minutes), width, base_w
)
chars, buckets = _layout(label_w)
volume = bucketize(stamps, start, now, buckets)
out: list[RenderableType] = []
out.append(body_heading("Volume", f"{len(observations)} receptions"))
out.extend(
axis_chart(
timeline_rows(volume, rows=_CHART_ROWS),
max(volume),
chars,
_time_axis(start, now),
label_w=label_w,
)
)
if snr_pairs:
medians = bucket_medians(snr_pairs, start, now, buckets)
lo, hi = chart_span(medians)
rows = timeline_rows(medians, rows=_SNR_ROWS, style=_snr_cell_style)
out.append(Text())
out.append(body_heading("SNR", "median dB per slice · grey line = 0"))
out.extend(
axis_chart(
rows,
hi,
chars,
_time_axis(start, now),
label_w=label_w,
floor=lo,
)
)
out.append(Text())
out.append(
body_heading("Rhythm", f"receptions by local time of day · {_slice_note(slice_minutes)}")
)
out.extend(
axis_chart(
timeline_rows(slots, rows=_CHART_ROWS),
max(slots),
len(slots) // 2,
_quarter_axis,
label_w=label_w,
)
)
out.append(Text())
out.append(body_heading("Record", "this window"))
first, last = observations[0].observed_at, observations[-1].observed_at
line = Text("heard ", style="muted")
line.append(f"first {_when_label(first)} · last {_when_label(last)}")
line.append(f" ({format_ago(age_seconds(last))})", style="muted")
out.append(line)
snrs = [p[1] for p in snr_pairs]
if snrs:
med = median(snrs)
line = Text("snr ", style="muted")
line.append(f"{med:+.1f} dB median", style=snr_style(med))
line.append(f" · worst {min(snrs):+.1f} · best {max(snrs):+.1f}", style="muted")
out.append(line)
rssis = [o.rssi for o in observations if o.rssi is not None]
if rssis:
out.append(
Text.assemble(
("rssi ", "muted"),
(f"{median(rssis):.0f} dBm median", ""),
(f" · weakest {min(rssis):.0f} · strongest {max(rssis):.0f}", "muted"),
)
)
kinds: dict[str, int] = {}
for o in observations:
kinds[o.kind] = kinds.get(o.kind, 0) + 1
parts = " · ".join(f"{kind} {count}" for kind, count in sorted(kinds.items()))
out.append(Text.assemble(("kinds ", "muted"), (parts, "")))
return out
# --- the own-node page -------------------------------------------------------------------
def _self_sections(ctx: AppContext, window: timedelta | None, width: int) -> list[RenderableType]:
"""Build the own-node page: transmission volume, reach SNR band, rhythm, and the ledger.
Our own node is the one subject the reception history can't describe — we never
overhear ourselves — so this page mirrors :func:`_node_sections` over the *outbound*
record instead: **Activity** is what we put on the air (every trace we launched and
message we sent) rather than what we heard; **Reach** charts our traces' bottleneck
SNR (how strongly we get out) where the node page charts a heard node's SNR; the
**Rhythm** is unchanged in shape — when do *we* transmit — and the **Ledger** rolls up
the tallies. It shares the node page's chart machinery and one common y-axis gutter, so
the two pages read as the same instrument pointed opposite ways.
"""
now = utcnow()
since = now - window if window is not None else None
stamps = ctx.repo.self_transmissions(since=since)
reach = ctx.repo.self_trace_reach(since=since)
ledger = ctx.repo.self_activity_ledger(since=since)
if not stamps:
return [
Text(),
Text("Nothing sent in this window.", style="muted"),
Text("Press w to widen it.", style="muted"),
]
start = since or stamps[0]
snr_pairs = [(when, float(snr)) for when, ok, snr, _hops in reach if ok and snr is not None]
# The rhythm folds every transmission into local time-of-day slices at the finest
# width the ladder fits (see :data:`_RHYTHM_SLICES`); a busy slice can top a single
# volume bucket, so its peak joins the shared-gutter sizing (see _node_sections for
# the same provisional-then-real width dance).
def _layout(label_w: int) -> tuple[int, int]:
# A chart row spends axis_chrome(label_w) beside its cells -- both gutters, as
# the platform draws them (the desktop mirrors its marks, the console doesn't).
chars = max(20, width - axis_chrome(label_w))
return chars, chars * 2
chars, buckets = _layout(1)
volume = bucketize(stamps, start, now, buckets)
lo, hi = chart_span(bucket_medians(snr_pairs, start, now, buckets)) if snr_pairs else (0.0, 0.0)
base_w = max(axis_label_w(max(volume), _CHART_ROWS), axis_label_w(hi, _SNR_ROWS, lo=lo))
slots, slice_minutes, label_w = _fit_rhythm(
lambda minutes: _rhythm_slots(stamps, minutes), width, base_w
)
chars, buckets = _layout(label_w)
volume = bucketize(stamps, start, now, buckets)
out: list[RenderableType] = []
out.append(body_heading("Activity", f"{len(stamps)} sent · traces + messages"))
out.extend(
axis_chart(
timeline_rows(volume, rows=_CHART_ROWS),
max(volume),
chars,
_time_axis(start, now),
label_w=label_w,
)
)
if snr_pairs:
medians = bucket_medians(snr_pairs, start, now, buckets)
lo, hi = chart_span(medians)
rows = timeline_rows(medians, rows=_SNR_ROWS, style=_snr_cell_style)
out.append(Text())
out.append(body_heading("Reach", "trace bottleneck dB per slice · grey line = 0"))
out.extend(
axis_chart(
rows,
hi,
chars,
_time_axis(start, now),
label_w=label_w,
floor=lo,
)
)
out.append(Text())
out.append(
body_heading("Rhythm", f"transmissions by local time of day · {_slice_note(slice_minutes)}")
)
out.extend(
axis_chart(
timeline_rows(slots, rows=_CHART_ROWS),
max(slots),
len(slots) // 2,
_quarter_axis,
label_w=label_w,
)
)
out.append(Text())
out.append(body_heading("Ledger", "this window"))
first, last = stamps[0], stamps[-1]
line = Text("active ", style="muted")
line.append(f"first {_when_label(first)} · last {_when_label(last)}")
line.append(f" ({format_ago(age_seconds(last))})", style="muted")
out.append(line)
if ledger.trace_total:
pct = round(100 * ledger.trace_ok / ledger.trace_total)
hops = [h for _w, ok, _s, h in reach if ok and h is not None]
line = Text("reach ", style="muted")
line.append(f"{ledger.trace_ok} of {ledger.trace_total} traces home")
line.append(f" ({pct}%)", style="muted")
if ledger.trace_targets:
line.append(f" · {ledger.trace_targets} targets", style="muted")
if hops:
line.append(f" · median {round(median(hops))} hops", style="muted")
out.append(line)
if snr_pairs:
snrs = [s for _w, s in snr_pairs]
med = median(snrs)
line = Text("snr ", style="muted")
line.append(f"{med:+.1f} dB median", style=snr_style(med))
line.append(f" · worst {min(snrs):+.1f} · best {max(snrs):+.1f}", style="muted")
out.append(line)
sent = _self_sent_line(ledger)
if sent is not None:
out.append(sent)
if ledger.tx_samples:
out.append(
Text.assemble(
("tx ", "muted"),
(f"{ledger.tx_samples} power samples", ""),
(" · reach vs. transmit power", "muted"),
)
)
return out
def _self_sent_line(ledger) -> Text | None: # noqa: ANN001 - SelfActivity, kept local
"""The Ledger's outbound-messages line: channel/direct counts and the DM ack rate.
Omitted entirely when nothing was sent in the window (``None``), so a trace-only
window doesn't print an empty ``sent`` lane. Channel broadcasts are never acked, so
the ack fraction is stated only over the direct messages whose ack was actually
tracked (:attr:`~meshterm.core.models.SelfActivity.dm_ackable`).
"""
if not (ledger.msg_channel or ledger.msg_dm):
return None
line = Text("sent ", style="muted")
parts: list[str] = []
if ledger.msg_channel:
parts.append(f"{ledger.msg_channel} channel")
if ledger.msg_dm:
parts.append(f"{ledger.msg_dm} direct")
line.append(" · ".join(parts))
if ledger.dm_ackable:
pct = round(100 * ledger.dm_acked / ledger.dm_ackable)
line.append(f" · {ledger.dm_acked}/{ledger.dm_ackable} acked ({pct}%)", style="muted")
if ledger.dm_peers:
line.append(f" · {ledger.dm_peers} peers", style="muted")
return line
# --- the mesh page -----------------------------------------------------------------------
def _day_spans(days: int, chars: int) -> list[tuple[int, int]]:
"""Each day's lit-bar ``(start, width)`` within the chart's ``2 × chars`` dots.
The one even split behind both the bars (:func:`_day_columns`) and their axis
ticks (:func:`_day_centers`). When every bar can keep at least two dots, each
of the ``n − 1`` day *boundaries* pays one unclaimed notch dot and the bars
split the rest with a floor-edge walk, so no two differ by more than one dot
column and the wider days interleave evenly among the narrower ones instead
of pooling at either end. The notches sit strictly between days: the first
bar starts flush at dot 0 (the axis border is separation enough — a leading
blank dot would double the left margin the braille glyphs already carry)
and the last ends flush at the right border, so both edges read alike. A
denser history drops the notches and the bars simply tile all ``2 × chars``
dots.
Args:
days: How many day bars the chart draws.
chars: The chart's width in character cells.
Returns:
One ``(start dot, width in dots)`` per lit bar, oldest first.
"""
dots = chars * 2
n = max(1, days)
lit = dots - (n - 1) # what the bars keep once each boundary pays its notch dot
notch = n > 1 and lit // n >= 2
if not notch:
lit = dots
edges = [i * lit // n for i in range(n + 1)]
return [(edges[i] + (i if notch else 0), edges[i + 1] - edges[i]) for i in range(n)]
def _day_centers(days: int, chars: int) -> list[int]:
"""The chart cell each day's bar is centred on, mirroring :func:`_day_columns`.
Reads the same :func:`_day_spans` split ``_day_columns`` draws with, so a tick
placed at ``centers[i]`` lands under day ``i``'s bar rather than at an
arbitrary fraction of the axis.
Args:
days: How many day bars the chart draws.
chars: The chart's width in character cells.
Returns:
One centre cell (``0 .. chars - 1``) per day, oldest first.
"""
return [min(chars - 1, (start + width // 2) // 2) for start, width in _day_spans(days, chars)]
def _even_picks(n: int, k: int) -> list[int]:
"""``k`` bar indices out of ``n``, evenly spread with both ends included.
The endpoints are always index ``0`` and ``n − 1``; the interior lands on the
even fractions between. Rounding can collapse two picks onto one bar when ``k``
approaches ``n`` on a short axis, so the result is de-duplicated and may hold
fewer than ``k`` — the caller treats that as "``k`` does not fit" and steps down.
Args:
n: How many bars the chart draws.
k: How many ticks to spread across them (``1 .. n``).
Returns:
The chosen bar indices, ascending.
"""
if n <= 0:
return []
if k <= 1:
return [0]
return sorted({round(i * (n - 1) / (k - 1)) for i in range(k)})
def _ticks_fit(picks: list[int], labels: list[str], centers: list[int], chars: int) -> bool:
"""Whether ``labels`` placed under ``picks`` clear each other on the axis.
Replays the exact placement :func:`~meshterm.ui.braillechart._tick_axis` runs —
each label centred on its bar's cell, clamped into the axis, needing
:data:`~meshterm.ui.braillechart._TICK_GAP` cells past the previous label's end —
and reports whether every one survives. Measuring the real labels (a bare ``"7"``
is a third the width of ``"Jul 12"``) is the point: a fixed worst-case budget per
label would drop ticks a variable-width axis has ample room for.
"""
last_end = -_TICK_GAP
for i, label in zip(picks, labels, strict=True):
cell = max(0, min(chars - 1, centers[i]))
start = max(0, min(chars - len(label), cell - len(label) // 2))
if start < last_end + _TICK_GAP:
return False
last_end = start + len(label)
return True
def _fit_ticks(
n: int, chars: int, centers: list[int], label_of: Callable[[list[int]], list[str]]
) -> list[tuple[int, str]]:
"""``(cell, label)`` ticks: as many bars as the *actual* labels leave room for.
Tries every bar first, then steps the tick count down until an evenly spread
subset (:func:`_even_picks`) clears the collision rule (:func:`_ticks_fit`) for
the labels it would actually draw. Both ends stay annotated at every count.
Because the labels are rebuilt for each candidate set — day numbers stay bare
until a month rollover forces ``"Jul 1"`` — the fit tracks their true widths
rather than reserving the longest label's width for all of them.
Args:
n: How many bars the chart draws.
chars: The chart's width in character cells.
centers: Each bar's centre cell (see :func:`_day_centers`).
label_of: Builds the labels for a set of picked bar indices, in context
(so month rollovers and the closing ``today``/``now`` land correctly).
Returns:
The ticks to pass to :func:`~meshterm.ui.braillechart.axis_chart`.
"""
if n <= 0:
return []
for k in range(min(n, chars), 1, -1):
picks = _even_picks(n, k)
if len(picks) < k:
continue # rounding fused two picks — this many will not fit cleanly
labels = label_of(picks)
if _ticks_fit(picks, labels, centers, chars):
return list(zip((centers[i] for i in picks), labels, strict=True))
picks = [0] if n == 1 else [0, n - 1]
return list(zip((centers[i] for i in picks), label_of(picks), strict=True))
def _day_ticks(shown: list, chars: int) -> list[tuple[int, str]]:
"""``(cell, label)`` axis ticks under the day bars: short dates, thinned to fit.
One tick per day where the labels all clear each other, else an evenly spaced
subset keeping both ends (see :func:`_fit_ticks`); each label sits under its own
bar (see :func:`_day_centers`). Dates are kept compact — the month is shown only
on the first tick and whenever it rolls over, so most ticks read as a bare day
number — and the newest bar reads ``today`` when it is, mirroring the node page's
closing ``now``.
Args:
shown: The charted days, oldest first, each ``(iso_date, ...)``.
chars: The chart's width in character cells.
Returns:
The ticks to pass to :func:`~meshterm.ui.braillechart.axis_chart`.
"""
n = len(shown)
centers = _day_centers(n, chars)
# The day keys are local calendar days (see Repository.daily_activity), so "today"
# is the local date too — datetime.now() is naive local, exactly what we compare.
today = datetime.now().strftime("%Y-%m-%d")
def label_of(picks: list[int]) -> list[str]:
labels: list[str] = []
prev_month: str | None = None
for i in picks:
iso = shown[i][0]
try:
day = datetime.strptime(iso, "%Y-%m-%d")
except ValueError:
labels.append(iso)
continue
month = day.strftime("%b")
if i == n - 1 and iso == today:
label = "today"
elif month != prev_month:
label = f"{month} {day.day}"
else:
label = str(day.day)
prev_month = month
labels.append(label)
return labels
return _fit_ticks(n, chars, centers, label_of)
def _hour_ticks(shown: list, chars: int) -> list[tuple[int, str]]:
"""``(cell, label)`` axis ticks under the hourly bars: local clock times, thinned to fit.
The hour-resolution sibling of :func:`_day_ticks` for the 24 h window: one tick
per hour where the labels fit, else an evenly spaced subset keeping both ends
(see :func:`_fit_ticks`), each label centred under its own bar (see
:func:`_day_centers`). The hour keys are already local (see
:meth:`~meshterm.persistence.repository.Repository.hourly_series`), so a label is
just its key's ``HH:00``, no conversion; the newest bar reads ``now``, mirroring
the node page's closing ``now``.
Args:
shown: The charted hours, oldest first, each ``(hour_iso, ...)`` a local
``YYYY-MM-DDTHH``.
chars: The chart's width in character cells.
Returns:
The ticks to pass to :func:`~meshterm.ui.braillechart.axis_chart`.
"""
n = len(shown)
centers = _day_centers(n, chars)
def label_of(picks: list[int]) -> list[str]:
labels: list[str] = []
for i in picks:
if i == n - 1:
labels.append("now")
continue
try:
hour = datetime.strptime(shown[i][0], "%Y-%m-%dT%H")
except ValueError:
labels.append(shown[i][0])
continue
labels.append(f"{hour:%H:00}")
return labels
return _fit_ticks(n, chars, centers, label_of)
def _fill_days(
active: list[tuple[str, int, int]], since: datetime | None, now: datetime
) -> list[tuple[str, int, int]]:
"""Fill a day series' gaps so an empty day shows as an empty bar, not a skip.
``daily_activity`` returns only days with traffic, so a quiet day used to vanish and
its busy neighbours fused — a fortnight with two dead days reading as twelve adjacent
bars. This walks every calendar day of the window and emits ``(iso, 0, 0)`` for the
silent ones, so the chart's x-axis is real calendar time. The range runs from the
window's floor (but never earlier than the first day ever recorded — we don't invent
emptiness from before monitoring began) through today.
Args:
active: ``(iso_day, packets, nodes)`` for days with activity, oldest first.
since: The window's start (``None`` = all of history).
now: The current time (the series ends on its calendar day).
Returns:
``(iso_day, packets, nodes)`` for every calendar day in range, oldest first.
"""
if not active:
return []
by_iso = {iso: (packets, nodes) for iso, packets, nodes in active}
# The keys are local calendar days, so bound the fill by local dates too — .date()
# on the raw UTC-aware since/now would floor a day early in western zones.
first = date.fromisoformat(active[0][0])
floor = since.astimezone().date() if since is not None else first
start = max(first, floor)
end = max(start, now.astimezone().date())
out: list[tuple[str, int, int]] = []
day = start
while day <= end:
iso = day.isoformat()
packets, nodes = by_iso.get(iso, (0, 0))
out.append((iso, packets, nodes))
day += timedelta(days=1)
return out
def _fill_hours(
active: list[tuple[str, int, int]], since: datetime, now: datetime
) -> list[tuple[str, int, int]]:
"""Fill an hour series' gaps so a quiet hour shows as an empty bar, not a skip.
The hour-resolution sibling of :func:`_fill_days`, feeding the 24 h window's
charts: :meth:`~meshterm.persistence.repository.Repository.hourly_series` returns
only hours with traffic, so a silent hour would fuse its busy neighbours. This
walks every local clock hour of the window and emits ``(iso, 0, 0)`` for the quiet
ones, so the x-axis is real clock time. The range runs from the window's floor
(but never earlier than the first hour recorded — we don't invent emptiness from
before monitoring began) through the current hour.
The keys are local wall-clock ``YYYY-MM-DDTHH`` (matching the SQL grouping), so the
walk steps a *naive* local clock: adding an hour advances the wall clock, which is
DST-robust — a spring-forward gap fills as an empty bar and a fall-back repeat sums
into one key, exactly as the grouping already did.
Args:
active: ``(hour_iso, packets, nodes)`` for hours with activity, oldest first,
each ``hour_iso`` a local ``YYYY-MM-DDTHH``.
since: The window's start.
now: The current time (the series ends on its clock hour).
Returns:
``(hour_iso, packets, nodes)`` for every local clock hour in range, oldest
first.
"""
if not active:
return []
def floor_hour(when: datetime) -> datetime:
return when.astimezone().replace(tzinfo=None, minute=0, second=0, microsecond=0)
by_iso = {iso: (packets, nodes) for iso, packets, nodes in active}
first = datetime.strptime(active[0][0], "%Y-%m-%dT%H")
start = max(first, floor_hour(since))
end = max(start, floor_hour(now))
out: list[tuple[str, int, int]] = []
hour = start
while hour <= end:
iso = hour.strftime("%Y-%m-%dT%H")
packets, nodes = by_iso.get(iso, (0, 0))
out.append((iso, packets, nodes))
hour += timedelta(hours=1)
return out
def _day_columns(values: list[int], chars: int) -> list:
"""Stretch per-day counts into day-wide bars that fill the chart's width exactly.
One dot column per day leaves a short history as a sliver in a wide terminal —
beneath how every other MeshTerm chart spends its width — so each day repeats
over its :func:`_day_spans` share of the chart's dot columns instead: an even
split whose widths never differ by more than one dot and whose total always
lands on exactly ``2 × chars`` (a plain floor division drops the remainder,
leaving the bars short of the axis border and caption sized for the full
width — misreading as the whole chart sitting shifted left).
The dots the split leaves unclaimed — one per day *boundary*, when the bars
are wide enough to afford them (see :func:`_day_spans`) — render as
:data:`~meshterm.ui.braillechart.GAP` columns, blank clean down to the axis,
so same-height neighbours read as separate bars instead of fusing into one
solid block. A deeper history (bars of one dot) has no boundary to spare and
the days simply abut.
Args:
values: Per-day counts, oldest first.
chars: The chart's width in character cells.
Returns:
Exactly ``2 × chars`` dot-column readings, oldest first (a
:data:`~meshterm.ui.braillechart.GAP` marks a day-boundary notch).
"""
out: list = [GAP] * (chars * 2)
for value, (start, width) in zip(values, _day_spans(len(values), chars), strict=True):
out[start : start + width] = [value] * width
return out
def _mesh_sections(
ctx: AppContext,
window: timedelta | None,
width: int,
prefix_bytes: int = 0,
resolve: NodeResolver = lambda label: label,
resolve_key: NodeResolver = lambda node: node,
) -> list[RenderableType]:
"""Build the whole-mesh overview: days, rhythm, arrivals, and the all-time ledger.
Args:
ctx: The shared application context (repository reads only).
window: The history window (``None`` = everything ever recorded).
width: Render width in columns.
prefix_bytes: The hash width to light at the head of each arrival's key (0 = none).
resolve: Names a node hash from the device's contacts, for arrivals whose
observations never carried a name.
resolve_key: Expands a stored 12-hex node id to the full public key when the
device holds it as a contact, so the arrivals' key lane can fill whatever
width the terminal offers (see :func:`_contact_resolvers`).
"""
now = utcnow()
since = now - window if window is not None else None
# The 24 h window charts an hour per column (its name is "24 h", not "1 day");
# every wider window keeps the calendar-day columns. Both feed the same bar and
# tick machinery — only the bucket resolution and the axis labels differ.
hourly = since is not None and window is not None and window <= timedelta(days=1)
# The all-days series feeds the ledger at the bottom too — fetched once here (it is
# one of the costlier scans), and only on demand in the hourly case.
all_days: list[tuple[str, int, int]] | None = None
if hourly:
series = _fill_hours(ctx.repo.hourly_series(since), since, now)
else:
all_days = ctx.repo.daily_activity()
series = _fill_days(all_days, since, now)
if not series:
return [
Text(),
Text("Nothing recorded in this window.", style="muted"),
Text("Press w to widen it.", style="muted"),
]
# The mesh-wide rhythm (charted below) folds the whole window into local time-of-day
# slices at the finest width the ladder fits (see :data:`_RHYTHM_SLICES`), re-sliced
# client-side from one minute-grid base scan. A busy slice's tally can top any single
# day's, so its peak joins the day peaks in sizing one shared y-axis gutter. The
# slices come back already in local time (rotated per-instant in SQL), no offset
# shuffle here.
minute_grid = ctx.repo.rhythm_activity(since=since)
# The y-axis gutter is sized from the whole window's peaks (not just the visible
# slice) and shared by every chart, so all their gutters — and thus their left edges —
# line up. The rhythm keeps its own finer width; only the gutter is common.
base_w = max(
axis_label_w(max(d[1] for d in series), _CHART_ROWS),
axis_label_w(max(d[2] for d in series), _CHART_ROWS),
)
slots, slice_minutes, label_w = _fit_rhythm(
lambda minutes: _fold_slots(minute_grid, minutes), width, base_w
)
chars = max(20, width - axis_chrome(label_w))
shown = series[-chars * 2 :]
out: list[RenderableType] = []
packets = [d[1] for d in shown]
ticks = _hour_ticks(shown, chars) if hourly else _day_ticks(shown, chars)
pkt_title, node_title = (
("Packets per hour", "Nodes per hour") if hourly else ("Packets per day", "Nodes per day")
)
out.append(body_heading(pkt_title, "local hours" if hourly else "local days"))
out.extend(
axis_chart(
timeline_rows(_day_columns(packets, chars), rows=_CHART_ROWS),
max(packets),
chars,
label_w=label_w,
ticks=ticks,
)
)
nodes = [d[2] for d in shown]
out.append(Text())
out.append(body_heading(node_title, "distinct nodes heard"))
out.extend(
axis_chart(
timeline_rows(_day_columns(nodes, chars), rows=_CHART_ROWS),
max(nodes),
chars,
label_w=label_w,
ticks=ticks,
)
)
# The node page's rhythm chart, mesh-wide: when does this *mesh* talk? The slices
# keep their own finer width but share the day charts' gutter, so this chart's left
# edge lines up with the two above it.
out.append(Text())
out.append(
body_heading("Rhythm", f"packets by local time of day · {_slice_note(slice_minutes)}")
)
out.extend(
axis_chart(
timeline_rows(slots, rows=_CHART_ROWS),
max(slots),
len(slots) // 2,
_quarter_axis,
label_w=label_w,
)
)
arrivals = ctx.repo.first_seen(since=since)
# One aggregation serves both the arrivals' key lane and the ledger's node count
# below (every heard node *is* a first-seen node, so the counts agree).
heard = ctx.repo.heard_nodes()
out.append(Text())
out.append(body_heading("Arrivals", "nodes heard for the first time ever"))
if not arrivals:
out.append(Text("none in this window", style="muted"))
else:
# Aligned lanes under column labels, the picker's presentation: the name in
# the node's hash-derived hue (a nameless arrival's "unknown" stays muted),
# the key lit at the routing width, the age glowing with recency heat.
# The FIRST HEARD header carries what used to be repeated on every row.
# A nameless arrival first asks the resolver (the device may know the node
# as a contact even though its stored observations never carried a name),
# and each key expands to its fullest known form the way the picker's lane
# does: the key captured with an observation (shows offline), else the
# device's contact list, else the stored 12-hex prefix.
stored_keys = {n.node: n.public_key for n in heard if n.node}
listed = [
(
stored_keys.get(node) or resolve_key(node) or node,
_known_name(resolve, node, name),
first,
)
for node, name, first in arrivals[:12]
]
name_w = min(
_PICK_NAME_MAX,
max([len("unknown"), *(len(n) for _node, n, _f in listed if n)]),
)
# The key lane absorbs whatever width the name lane and the fixed FIRST
# HEARD tail leave, floored at the old fixed lane — as many whole bytes as
# fit, ellipsized past that on a byte boundary (highlighted_hash's contract).
key_w = max(_PICK_HASH_W, width - name_w - _ARRIVAL_TAIL)
out.append(
Text(
" " + "NAME".ljust(name_w + 2) + "KEY".ljust(key_w + 2) + "FIRST HEARD",
style="muted",
)
)
for key, name, first in listed:
secs = age_seconds(first)
line = Text(" ", no_wrap=True, overflow="ellipsis")
line.append(
fit_cells(name or "unknown", name_w),
style=name_style(name, key) if name else "muted",
)
line.append(" ")
line.append_text(highlighted_hash(key, prefix_bytes, width=key_w, known=bool(name)))
line.append(" ")
line.append(_when_label(first))
line.append(f" ({format_ago(secs)})", style=_recency_style(secs))
out.append(line)
if all_days is None:
all_days = ctx.repo.daily_activity()
total_nodes = sum(1 for n in heard if n.node)
busiest = max(all_days, key=lambda d: d[1])
out.append(Text())
out.append(body_heading("Ledger", "everything ever recorded"))
out.append(
Text.assemble(
("history ", "muted"),
(f"{ctx.repo.observation_count()} observations", ""),
(f" across {len(all_days)} active days · {total_nodes} nodes", "muted"),
)
)
out.append(
Text.assemble(
("busiest ", "muted"),
(busiest[0], "brand"),
(f" {busiest[1]} packets", "muted"),
)
)
return out
# --- the picker loop ---------------------------------------------------------------------
#: Widest the mesh page's arrivals name lane grows (longer names ellipsize so the lanes
#: stay put). The picker sizes its own name lane to content instead (see
#: :meth:`~meshterm.ui.contactlist.ContactListScreen._lane_widths`).
_PICK_NAME_MAX = 18
#: The mesh page's arrivals key lane's *floor* in cells (a very narrow terminal). The lane
#: otherwise flexes to fill the width the name lane and the FIRST HEARD tail leave, so a
#: resolved full key shows as many whole bytes as fit (see the ``key_w`` math above).
_PICK_HASH_W = 16
#: Everything in an arrival row *besides* the name and key lanes, in cells: the 2-cell
#: indent, the two 2-cell lane gaps, the ``Jul 04 18:30`` stamp (12), and the recency
#: parenthetical (`` (259w ago)`` at its widest, 12).
_ARRIVAL_TAIL = 2 + 2 + 2 + 12 + 12
def _known_name(resolve: NodeResolver, node: str | None, name: str | None) -> str | None:
"""The best display name for a heard node, or ``None`` when truly unknown.
The app-wide fill-in-the-blanks rule: a node id without a stored name is not
necessarily a mystery — the device's contact list may know it. The stored name
wins (it's what the node itself last put on the air); the resolver fills the
blanks; only a node neither source can name stays ``unknown``.
"""
if name:
return name
if node:
named = resolve(node)
if named and named != node:
return named
return None
async def _contact_resolvers(
ctx: AppContext,
) -> tuple[NodeResolver, Callable[[str | None], int | None], NodeResolver]:
"""A ``(name, type, key)`` resolver trio over the contacts, best-effort like the prefix read.
All three fill the picker's (and arrivals') blanks from what the companion knows but the
stored observations never carried: the name resolver names a node the device holds as a
contact even though its history was nameless (a telemetry-only sensor, a repeater heard
before it advertised); the type resolver supplies its node *type* the same way, so the
row's leading glyph reads ``▲`` for a repeater rather than the plain-node ``●`` fallback;
the key resolver expands a stored 12-hex prefix to the contact's full public key, so the
hash lane shows more than the twelve stored digits when there's room. Contacts are read
once and all three built from them. With no device reachable all three simply know
nothing, and the stored names, types, and 12-hex prefixes stand alone.
"""
from ..services.trace_runner import (
make_key_resolver,
make_node_resolver,
make_node_type_resolver,
)
contacts = []
try:
if ctx.is_connected or ctx.settings.connect_on_start:
contacts = await ctx.devstate.contacts()
except Exception: # noqa: BLE001 - optional read; absence just leaves names/types stored-only
contacts = []
return (
make_node_resolver(contacts),
make_node_type_resolver(contacts),
make_key_resolver(contacts),
)
async def _self_identity(ctx: AppContext) -> tuple[str | None, str | None]:
"""Our own node's ``(name, public_key)`` for its picker lane, best-effort like the prefix read.
The own-node page needs no device — it reads stored history — so this fetch only dresses
the row: an unreachable device (or firmware that won't answer) simply leaves the name a
bare ``you`` and the hash a ``?``.
"""
try:
if not (ctx.is_connected or ctx.settings.connect_on_start):
return None, None
info = await ctx.devstate.self_info()
except Exception: # noqa: BLE001 - optional read; absence just leaves the lane undressed
return None, None
if not isinstance(info, dict):
return None, None
name, key = info.get("name"), info.get("public_key")
return (str(name) if name else None), (str(key) if key else None)
class TimeMachinePickerScreen(ContactListScreen):
"""The Time Machine's subject picker: the shared list under the whole-mesh row.
The whole-mesh overview leads; under it runs the app's shared sortable list
(see :class:`~meshterm.ui.contactlist.ContactListScreen` for the lanes, the Ctrl+arrow
sort, and type-to-filter) — our own node first, then every node ever heard. A heard
node's hash lane shows its fullest known key: the one captured with an observation
(shows offline), else the device's contact list, else the stored 12-hex prefix.
"""
def __init__(
self,
*,
listed: list[tuple[HeardNode, str | None]],
prefix_bytes: int,
sort: ContactsSort,
prompt: str,
self_name: str | None = None,
self_key: str | None = None,
type_of: Callable[[str | None], int | None] = lambda _node: None,
resolve_key: Callable[[str | None], str | None] = lambda node: node,
) -> None:
"""Build the picker over already-resolved ``(node, name)`` pairs.
Args:
listed: Every heard node with a resolved display name (``None`` = unknown),
in the repository's most-recently-heard order; re-sorted per ``sort``.
prefix_bytes: Path-hash width in bytes to light in each hash.
sort: The sort state, mutated in place by the Ctrl+arrows — pass the same
instance across re-opens so the chosen order persists. Its ring should span
:data:`~meshterm.ui.contactlist.SORT_COLUMNS` for the hash column to be
reachable.
prompt: The instruction shown above the list.
self_name: Our own node's advertised name, for the own-node lane that always
leads the node list; ``None`` (no reachable device) falls back to a bare
``you``. The row is always present — the own-node page reads stored history.
self_key: Our own node's full public key, lit as the own-node lane's hash;
``None`` renders a muted ``?``.
type_of: Resolves a node's type from the device's contacts, filling the leading
glyph for a node whose stored observations never carried one (see
:func:`_contact_resolvers`); the default knows nothing, leaving the glyph on
the stored type alone.
resolve_key: Expands a heard node's stored 12-hex key prefix to its full public
key when the device holds it as a contact, so the hash lane shows more than
the stored digits (see :func:`_contact_resolvers`); the default returns the
prefix unchanged, leaving the stored 12 hex to stand.
"""
rows = [ContactRow(value=SELF, name=self_name, key=self_key or "", you=True)]
for node, name in listed:
rows.append(
ContactRow(
value=(node.node, name or node.node),
name=name,
# The full key, most-durable source first: the one captured with the
# observation (shows offline), else the device's live contact list,
# else the stored prefix.
key=node.public_key or resolve_key(node.node) or node.node or "",
node_type=(
node.node_type if node.node_type is not None else type_of(node.node)
),
last_seen=node.last_seen,
count=node.count,
)
)
super().__init__(
"Time machine",
rows=rows,
prefix_bytes=prefix_bytes,
sort=sort,
prompt=prompt,
lead=[
Choice(command_label("🌐 The whole mesh — days, arrivals, the ledger"), MESH),
Separator(" "),
section_heading("Nodes"),
],
)
async def open_timemachine(ctx: AppContext) -> None:
"""Run the Time Machine: pick a subject, explore its page, repeat until Esc.
Args:
ctx: The shared application context (must be running the interactive TUI).
Raises:
RuntimeError: If called outside the interactive menu (no full-screen session).
"""
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
raise RuntimeError("the time machine is only available in the menu")
session = ctx.ui.session
prefix_bytes = await ctx.devstate.routing_prefix_bytes()
resolve, type_of, resolve_key = await _contact_resolvers(ctx)
self_name, self_key = await _self_identity(ctx)
# One sort for the whole visit, so the order the user picks survives leaving a subject
# page and coming back. Opens most-recently-heard first, as the list always has; its ring
# spans the shared list's four columns so the Ctrl+arrows can reach the hash sort.
sort = ContactsSort.from_name("heard", SORT_COLUMNS, SORT_OPENS_ASCENDING)
while True:
heard = ctx.repo.heard_nodes()
if not heard:
await session.message_dialog(
Text(
"History is empty — the recorder fills it passively while "
"MeshTerm listens. Come back after the mesh has talked a while.",
style="muted",
),
title="Time machine",
)
return
# Stored names first, the contact resolver filling the blanks (the app-wide
# rule: a node we *can* name never shows as unknown).
listed = [(node, _known_name(resolve, node.node, node.name)) for node in heard if node.node]
picker = TimeMachinePickerScreen(
listed=listed,
prefix_bytes=prefix_bytes,
sort=sort,
prompt="Everything the recorder ever heard, explorable:",
self_name=self_name,
self_key=self_key,
type_of=type_of,
resolve_key=resolve_key,
)
# The picker stays pushed for the whole visit, so a subject page nests above it and
# Esc lands back on the row it was opened from — cursor, sort and filter intact. The
# recorder keeps listening the whole time, though, so the subjects it knows can grow
# while a page is open; the list is rebuilt (and the place lost) only when it has.
subjects = {node.node for node, _ in listed}
async with session.stay(picker) as visit:
while True:
result = await visit.result()
picked = None if result is CANCEL else result
if picked is None:
return
if picked == SELF:
label = self_name or "you"
build = lambda window, width: _self_sections( # noqa: E731
ctx, window, width
)
elif picked == MESH:
label = "the whole mesh"
build = ( # noqa: E731
lambda window, width, _pb=prefix_bytes: _mesh_sections(
ctx, window, width, _pb, resolve, resolve_key
)
)
else:
node_id, label = picked
build = ( # noqa: E731 - a tiny binding closure beats a def here
lambda window, width, _id=node_id, _lb=label: _node_sections(
ctx, _id, _lb, window, width
)
)
screen = TimeMachineScreen(session=session, label=label, build=build)
await session.run_screen(screen)
if {n.node for n in ctx.repo.heard_nodes() if n.node} != subjects:
break # the mesh spoke while the page was open; relist
async def open_timemachine_node(ctx: AppContext, node_id: str, label: str) -> None:
"""Open one node's Time Machine page directly, skipping the subject picker.
The Node detail screen's *Time machine* link lands here: the same scrollable per-node
page :func:`open_timemachine` reaches through its picker (volume, SNR band, hour-of-day
rhythm, roll-up), but for a node already chosen elsewhere. Reads stored history only, so
no device is needed and nothing transmits; runs until dismissed with Esc.
Args:
ctx: The shared application context (must be running the interactive TUI).
node_id: The node's stored 12-hex key-prefix id (what observations carry).
label: The node's display name for the page heading.
Raises:
RuntimeError: If called outside the interactive menu (no full-screen session).
"""
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
raise RuntimeError("the time machine is only available in the menu")
session = ctx.ui.session
build = ( # noqa: E731 - a tiny binding closure reads better than a def here
lambda window, width, _id=node_id, _lb=label: _node_sections(ctx, _id, _lb, window, width)
)
await session.run_screen(TimeMachineScreen(session=session, label=label, build=build))
async def open_timemachine_self(ctx: AppContext) -> None:
"""Open our own node's Time Machine page directly (the outbound-activity ledger).
The Node detail screen's *Time machine* link for our own node: we never overhear
ourselves, so this page is the traces we launched and the messages we sent, not a
reception history (see :func:`_self_sections`). Reads stored history only; runs until
dismissed with Esc.
Args:
ctx: The shared application context (must be running the interactive TUI).
Raises:
RuntimeError: If called outside the interactive menu (no full-screen session).
"""
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
raise RuntimeError("the time machine is only available in the menu")
session = ctx.ui.session
self_name, _ = await _self_identity(ctx)
label = self_name or "you"
build = lambda window, width: _self_sections(ctx, window, width) # noqa: E731
await session.run_screen(TimeMachineScreen(session=session, label=label, build=build))