Time Machine: intermediate time marks, contact names fill the blanks
Chart captions now draw through axis_caption, so a wide volume/SNR/day chart gains quarter/mid time marks between its edge labels. Nodes whose stored observations never carried a name ask the device's contact list before rendering as "unknown" (picker and arrivals alike), and the hash lanes go through the shared hash widget. make_node_resolver learns an optional stored-names fallback and the repository can serve the latest advertised name per node, for the dashboard feed to use next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b50d201c24
commit
30498efe0b
4 files changed
+139
-40
No files matched your search
@@ -831,6 +831,24 @@ class Repository:
|
||||
arrivals.sort(key=lambda t: t[2], reverse=True)
|
||||
return arrivals
|
||||
|
||||
def node_names(self) -> dict[str, str]:
|
||||
"""The most recent advertised name per node, across everything ever recorded.
|
||||
|
||||
The fill-in-the-blanks source for screens that meet a node id without a name
|
||||
(a telemetry-only node in the Time Machine, a relay hash in the dashboard
|
||||
feed): whatever name that node *ever* put on the air. One indexed scan;
|
||||
``packet`` rows are excluded because they carry no reliable identity.
|
||||
|
||||
Returns:
|
||||
Latest non-empty name keyed by stored node id (the 12-hex key prefix).
|
||||
"""
|
||||
rows = self._conn.execute(
|
||||
"SELECT node, name FROM observations "
|
||||
"WHERE node IS NOT NULL AND name IS NOT NULL AND name != '' "
|
||||
"AND kind != 'packet' ORDER BY observed_at"
|
||||
).fetchall()
|
||||
return {row["node"]: row["name"] for row in rows}
|
||||
|
||||
def heard_nodes(self, *, since: Optional[datetime] = None) -> list[HeardNode]:
|
||||
"""Aggregate stored observations into per-node reception statistics.
|
||||
|
||||
|
||||
@@ -26,16 +26,23 @@ NodeResolver = Callable[[Optional[str]], Optional[str]]
|
||||
_HEX_DIGITS = frozenset("0123456789abcdef")
|
||||
|
||||
|
||||
def make_node_resolver(contacts: Optional[list[Contact]]) -> NodeResolver:
|
||||
def make_node_resolver(
|
||||
contacts: Optional[list[Contact]],
|
||||
stored_names: Optional[dict[str, str]] = None,
|
||||
) -> NodeResolver:
|
||||
"""Build a resolver that names a trace hop from its key-prefix hash.
|
||||
|
||||
Trace replies identify each repeater only by a short hash — a leading slice of
|
||||
its public key. This maps that hash to the contact's friendly name when we know
|
||||
the node, so results read as names instead of opaque hex, and falls back to the
|
||||
raw hash for unknown nodes.
|
||||
raw hash for unknown nodes. ``stored_names`` widens the net beyond the device's
|
||||
contact list to names the recorder has *ever* overheard (the repository's
|
||||
latest advertised name per node), so a node the companion never befriended — or
|
||||
has since forgotten — still resolves; contacts are listed first, so they win.
|
||||
|
||||
Args:
|
||||
contacts: Known contacts to resolve against.
|
||||
stored_names: Fallback names keyed by stored node id (a hex key prefix).
|
||||
|
||||
Returns:
|
||||
A callable taking a hop label and returning a contact name when the hash
|
||||
@@ -49,6 +56,10 @@ def make_node_resolver(contacts: Optional[list[Contact]]) -> NodeResolver:
|
||||
prefix = (c.key_prefix or "").lower().removeprefix("0x")
|
||||
if c.name and (pub or prefix):
|
||||
entries.append((c.name, pub, prefix))
|
||||
for node, name in (stored_names or {}).items():
|
||||
ident = node.lower().removeprefix("0x")
|
||||
if name and ident:
|
||||
entries.append((name, "", ident))
|
||||
|
||||
def resolve(label: Optional[str]) -> Optional[str]:
|
||||
if not label:
|
||||
|
||||
@@ -30,7 +30,7 @@ from rich.console import Group, RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
from ..core.models import utcnow
|
||||
from .braillechart import chart_span, timeline_rows
|
||||
from .braillechart import axis_caption, chart_span, timeline_rows
|
||||
from .theme import snr_style
|
||||
from .tui.render import render_lines
|
||||
from .tui.screen import Screen
|
||||
@@ -46,6 +46,7 @@ from .widgets import (
|
||||
if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
from ..core.models import HeardNode
|
||||
from ..services.trace_runner import NodeResolver
|
||||
|
||||
#: The switchable history windows (``None`` = everything ever recorded).
|
||||
_WINDOWS: tuple[tuple[str, Optional[timedelta]], ...] = (
|
||||
@@ -98,23 +99,38 @@ def _snr_cell_style(values: list[float]) -> str:
|
||||
|
||||
|
||||
def _chart_block(
|
||||
rows: list[Text], left_caption: str, right_caption: str, chars: int
|
||||
rows: list[Text], label_at: Callable[[float], str], chars: int
|
||||
) -> list[RenderableType]:
|
||||
"""Indent chart rows into the gutter and add the oldest→now caption line."""
|
||||
"""Indent chart rows into the gutter and add the oldest→now caption line.
|
||||
|
||||
The caption is the shared :func:`~meshterm.ui.braillechart.axis_caption`, so a
|
||||
wide chart gains intermediate time marks between its two edge labels for free.
|
||||
"""
|
||||
out: list[RenderableType] = []
|
||||
for row in rows:
|
||||
line = Text(" ")
|
||||
line.append_text(row)
|
||||
out.append(line)
|
||||
caption = Text(" ")
|
||||
caption.append(left_caption, style="faint")
|
||||
pad = chars - len(left_caption) - len(right_caption)
|
||||
caption.append(" " * max(1, pad))
|
||||
caption.append(right_caption, style="faint")
|
||||
caption.append_text(axis_caption(chars, label_at))
|
||||
out.append(caption)
|
||||
return out
|
||||
|
||||
|
||||
def _time_axis(start: datetime, end: datetime) -> Callable[[float], str]:
|
||||
"""An axis labeller over a real time span: timestamps left of the closing ``now``."""
|
||||
def label_at(frac: float) -> str:
|
||||
if frac >= 1.0:
|
||||
return "now"
|
||||
return _when_label(start + (end - start) * frac)
|
||||
return label_at
|
||||
|
||||
|
||||
def _hour_axis(frac: float) -> str:
|
||||
"""The rhythm charts' labeller: the local hour of day at ``frac`` of the sweep."""
|
||||
return f"{round(frac * 23)} 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")
|
||||
@@ -223,7 +239,7 @@ def _node_sections(
|
||||
out.extend(
|
||||
_chart_block(
|
||||
timeline_rows(bucketize(stamps, start, now, buckets), rows=_CHART_ROWS),
|
||||
_when_label(start), "now", chars,
|
||||
_time_axis(start, now), chars,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -241,14 +257,14 @@ def _node_sections(
|
||||
f"median per slice · grey line = 0 · scale {lo:+.1f} → {hi:+.1f} dB",
|
||||
)
|
||||
)
|
||||
out.extend(_chart_block(rows, _when_label(start), "now", chars))
|
||||
out.extend(_chart_block(rows, _time_axis(start, now), chars))
|
||||
|
||||
hours = [0] * 24
|
||||
for stamp in stamps:
|
||||
hours[stamp.astimezone().hour] += 1
|
||||
out.append(Text())
|
||||
out.append(_heading("Rhythm", "receptions by local hour of day"))
|
||||
out.extend(_chart_block(timeline_rows(hours, rows=_CHART_ROWS), "0 h", "23 h", 12))
|
||||
out.extend(_chart_block(timeline_rows(hours, rows=_CHART_ROWS), _hour_axis, 12))
|
||||
|
||||
out.append(Text())
|
||||
out.append(_heading("Record", "this window"))
|
||||
@@ -283,11 +299,19 @@ def _node_sections(
|
||||
# --- the mesh page -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _day_axis(shown: list) -> Callable[[float], str]:
|
||||
"""An axis labeller over the day charts: the UTC date at ``frac`` of the sweep."""
|
||||
def label_at(frac: float) -> str:
|
||||
return shown[min(len(shown) - 1, round(frac * (len(shown) - 1)))][0]
|
||||
return label_at
|
||||
|
||||
|
||||
def _mesh_sections(
|
||||
ctx: "AppContext",
|
||||
window: Optional[timedelta],
|
||||
width: int,
|
||||
prefix_bytes: int = 0,
|
||||
resolve: "NodeResolver" = lambda label: label,
|
||||
) -> list[RenderableType]:
|
||||
"""Build the whole-mesh overview: days, rhythm, arrivals, and the all-time ledger.
|
||||
|
||||
@@ -296,6 +320,8 @@ def _mesh_sections(
|
||||
window: The history window (``None`` = everything ever recorded).
|
||||
width: Render width in columns.
|
||||
prefix_bytes: Path-hash width to light in the arrival hashes (0 = none).
|
||||
resolve: Names a node hash from the device's contacts, for arrivals whose
|
||||
observations never carried a name.
|
||||
"""
|
||||
now = utcnow()
|
||||
since = now - window if window is not None else None
|
||||
@@ -319,18 +345,14 @@ def _mesh_sections(
|
||||
_heading("Packets per day", f"UTC days · peak {max(packets)} · today at the right")
|
||||
)
|
||||
out.extend(
|
||||
_chart_block(
|
||||
timeline_rows(packets, rows=_CHART_ROWS), shown[0][0], shown[-1][0], chars
|
||||
)
|
||||
_chart_block(timeline_rows(packets, rows=_CHART_ROWS), _day_axis(shown), chars)
|
||||
)
|
||||
|
||||
nodes = [d[2] for d in shown]
|
||||
out.append(Text())
|
||||
out.append(_heading("Nodes per day", f"distinct nodes heard · peak {max(nodes)}"))
|
||||
out.extend(
|
||||
_chart_block(
|
||||
timeline_rows(nodes, rows=_CHART_ROWS), shown[0][0], shown[-1][0], chars
|
||||
)
|
||||
_chart_block(timeline_rows(nodes, rows=_CHART_ROWS), _day_axis(shown), chars)
|
||||
)
|
||||
|
||||
# The node page's rhythm chart, mesh-wide: when does this *mesh* talk? Hours are
|
||||
@@ -343,7 +365,7 @@ def _mesh_sections(
|
||||
hours = [utc_hours[(h - offset) % 24] for h in range(24)]
|
||||
out.append(Text())
|
||||
out.append(_heading("Rhythm", "packets by local hour of day"))
|
||||
out.extend(_chart_block(timeline_rows(hours, rows=_CHART_ROWS), "0 h", "23 h", 12))
|
||||
out.extend(_chart_block(timeline_rows(hours, rows=_CHART_ROWS), _hour_axis, 12))
|
||||
|
||||
arrivals = ctx.repo.first_seen(since=since)
|
||||
out.append(Text())
|
||||
@@ -354,7 +376,12 @@ def _mesh_sections(
|
||||
# Aligned lanes under column labels, the picker's presentation: the name
|
||||
# coloured by heat ("unknown" included), the hash lit at the routing width.
|
||||
# The FIRST HEARD header carries what used to be repeated on every row.
|
||||
listed = arrivals[:12]
|
||||
# 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).
|
||||
listed = [
|
||||
(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)]),
|
||||
@@ -373,9 +400,8 @@ def _mesh_sections(
|
||||
line = Text(" ", no_wrap=True, overflow="ellipsis")
|
||||
line.append(_fit(name or "unknown", name_w), style=_recency_style(secs))
|
||||
line.append(" ")
|
||||
shown_hash = node[:_PICK_HASH_W]
|
||||
line.append_text(highlighted_hash(shown_hash, prefix_bytes))
|
||||
line.append(" " * (_PICK_HASH_W - len(shown_hash) + 2))
|
||||
line.append_text(highlighted_hash(node, prefix_bytes, width=_PICK_HASH_W))
|
||||
line.append(" ")
|
||||
line.append(_when_label(first))
|
||||
line.append(f" ({_format_age(secs)} ago)", style="muted")
|
||||
out.append(line)
|
||||
@@ -417,6 +443,23 @@ def _fit(text: str, width: int) -> str:
|
||||
return text.ljust(width)
|
||||
|
||||
|
||||
def _known_name(resolve: "NodeResolver", node: Optional[str], name: Optional[str]) -> Optional[str]:
|
||||
"""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
|
||||
|
||||
|
||||
def _picker_header(name_w: int) -> str:
|
||||
"""Column labels over the node picker's lanes (see :func:`_picker_row`).
|
||||
|
||||
@@ -433,25 +476,25 @@ def _picker_header(name_w: int) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _picker_row(node: "HeardNode", name_w: int, prefix_bytes: int) -> Text:
|
||||
def _picker_row(node: "HeardNode", name: Optional[str], name_w: int, prefix_bytes: int) -> Text:
|
||||
"""One heard node as fixed, colour-coded picker lanes.
|
||||
|
||||
The type glyph leads (the app's shared marker palette), the name is coloured by
|
||||
recency heat — ``unknown`` included, so a freshly heard mystery node still reads
|
||||
hot — and the hash is a separate lane with its path-hash prefix lit at the
|
||||
device's routing width, exactly as the Nodes list draws keys. Packet count and
|
||||
age close the row, right-aligned under their headers.
|
||||
The type glyph leads (the app's shared marker palette), the name — stored, or
|
||||
filled in by the contact resolver (see :func:`_known_name`) — is coloured by
|
||||
recency heat, ``unknown`` included, so a freshly heard mystery node still reads
|
||||
hot. The hash is a separate lane in the shared hash widget, its path-hash prefix
|
||||
lit at the device's routing width, exactly as the Nodes list draws keys. Packet
|
||||
count and age close the row, right-aligned under their headers.
|
||||
"""
|
||||
glyph, glyph_style = _NODE_GLYPHS.get(node.node_type, _DEFAULT_GLYPH)
|
||||
secs = _age_seconds(node.last_seen)
|
||||
row = Text(no_wrap=True, overflow="ellipsis")
|
||||
row.append(glyph, style=glyph_style)
|
||||
row.append(" ")
|
||||
row.append(_fit(node.name or "unknown", name_w), style=_recency_style(secs))
|
||||
row.append(_fit(name or "unknown", name_w), style=_recency_style(secs))
|
||||
row.append(" ")
|
||||
row.append_text(highlighted_hash(node.node or "", prefix_bytes, width=_PICK_HASH_W))
|
||||
row.append(" ")
|
||||
shown = (node.node or "")[:_PICK_HASH_W]
|
||||
row.append_text(highlighted_hash(shown, prefix_bytes))
|
||||
row.append(" " * (_PICK_HASH_W - len(shown) + 2))
|
||||
row.append(f"{min(node.count, 99999):>5}", style="muted")
|
||||
row.append(" ")
|
||||
row.append(f"{_format_age(secs):>5}", style="muted")
|
||||
@@ -475,6 +518,26 @@ async def _routing_prefix_bytes(ctx: "AppContext") -> int:
|
||||
return (mode + 1) if isinstance(mode, int) and 0 <= mode <= 3 else 0
|
||||
|
||||
|
||||
async def _contact_resolver(ctx: "AppContext") -> "NodeResolver":
|
||||
"""A name resolver over the device's contacts, best-effort like the prefix read.
|
||||
|
||||
Fills the picker's and arrivals' ``unknown`` blanks for nodes the companion
|
||||
knows as contacts but whose stored observations never carried a name (a
|
||||
telemetry-only sensor, a repeater heard before it advertised). With no device
|
||||
reachable the resolver simply knows nothing, and the stored names stand alone.
|
||||
"""
|
||||
from ..services.trace_runner import make_node_resolver
|
||||
|
||||
contacts = []
|
||||
try:
|
||||
if ctx.is_connected or ctx.settings.connect_on_start:
|
||||
device = await ctx.device()
|
||||
contacts = await device.get_contacts()
|
||||
except Exception: # noqa: BLE001 - optional read; absence just leaves names stored-only
|
||||
contacts = []
|
||||
return make_node_resolver(contacts)
|
||||
|
||||
|
||||
async def open_timemachine(ctx: "AppContext") -> None:
|
||||
"""Run the Time Machine: pick a subject, explore its page, repeat until Esc.
|
||||
|
||||
@@ -491,6 +554,7 @@ async def open_timemachine(ctx: "AppContext") -> None:
|
||||
raise RuntimeError("the time machine is only available in the menu")
|
||||
session = ctx.ui.session
|
||||
prefix_bytes = await _routing_prefix_bytes(ctx)
|
||||
resolve = await _contact_resolver(ctx)
|
||||
|
||||
while True:
|
||||
heard = ctx.repo.heard_nodes()
|
||||
@@ -504,10 +568,16 @@ async def open_timemachine(ctx: "AppContext") -> None:
|
||||
title="⏳ Time machine",
|
||||
)
|
||||
return
|
||||
listed = [node for node in heard if node.node]
|
||||
# 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
|
||||
]
|
||||
name_w = min(
|
||||
_PICK_NAME_MAX,
|
||||
max([len("unknown"), *(len(n.name) for n in listed if n.name)]),
|
||||
max([len("unknown"), *(len(n) for _node, n in listed if n)]),
|
||||
)
|
||||
items: list = [
|
||||
Choice("🌐 The whole mesh — days, arrivals, the ledger", MESH),
|
||||
@@ -515,11 +585,11 @@ async def open_timemachine(ctx: "AppContext") -> None:
|
||||
Separator("Nodes, most recently heard first", style="accent"),
|
||||
Separator(_picker_header(name_w)),
|
||||
]
|
||||
for node in listed:
|
||||
for node, name in listed:
|
||||
items.append(
|
||||
Choice(
|
||||
_picker_row(node, name_w, prefix_bytes),
|
||||
(node.node, node.name or node.node),
|
||||
_picker_row(node, name, name_w, prefix_bytes),
|
||||
(node.node, name or node.node),
|
||||
)
|
||||
)
|
||||
picked = await session.select(
|
||||
@@ -533,7 +603,7 @@ async def open_timemachine(ctx: "AppContext") -> None:
|
||||
label = "the whole mesh"
|
||||
build = ( # noqa: E731
|
||||
lambda window, width, _pb=prefix_bytes: _mesh_sections(
|
||||
ctx, window, width, _pb
|
||||
ctx, window, width, _pb, resolve
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -204,7 +204,7 @@ def test_picker_row_lanes_align_under_the_header() -> None:
|
||||
node="3d" * 6, name=None, count=42, median_snr=None, best_snr=None,
|
||||
last_rssi=None, last_seen=utcnow(),
|
||||
)
|
||||
row = _picker_row(node, 10, 2)
|
||||
row = _picker_row(node, node.name, 10, 2)
|
||||
plain = row.plain
|
||||
assert "unknown" in plain and "3d" * 6 in plain and "42" in plain
|
||||
# A just-heard mystery node reads hot (white), not placeholder-grey.
|
||||
|
||||
Reference in new issue
Block a user