`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>
1431 lines
61 KiB
Python
1431 lines
61 KiB
Python
"""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,
|
||
_age_seconds,
|
||
_recency_style,
|
||
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"
|
||
|
||
|
||
def _heading(title: str, note: str) -> Text:
|
||
"""A section heading in the dashboard's voice: accent title, muted note."""
|
||
text = Text(title, style="accent")
|
||
text.append(f" · {note}", style="muted")
|
||
return text
|
||
|
||
|
||
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(_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(_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(
|
||
_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(_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(_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(_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(
|
||
_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(_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(_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(_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(_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(_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(_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 _routing_prefix_bytes(ctx: AppContext) -> int:
|
||
"""The device's path-hash width in bytes, or 0 when unknowable.
|
||
|
||
Best-effort, exactly like the Nodes tool: the Time Machine reads stored history
|
||
and must work with no radio at all, so an unreachable device (or firmware that
|
||
doesn't report the mode) just leaves every hash un-highlighted.
|
||
"""
|
||
try:
|
||
if not (ctx.is_connected or ctx.settings.connect_on_start):
|
||
return 0
|
||
mode = await ctx.devstate.path_hash_mode()
|
||
except Exception: # noqa: BLE001 - optional read; absence just skips highlighting
|
||
return 0
|
||
return (mode + 1) if isinstance(mode, int) and 0 <= mode <= 3 else 0
|
||
|
||
|
||
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 _routing_prefix_bytes(ctx)
|
||
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))
|