`meshterm contacts --json` is what a person types, and it failed with a bare usage error: Click binds an option to whatever command declares it, so a global on the callback is a parse error one word later. For an output format that is the wrong way round — `-o json` goes after the verb in every tool that has one, and --json is the first thing anyone reaches for. The group's own options are now lifted to the front before Click parses, so both orders mean the same run. `--` stops the lifting and --help is never lifted, since `contacts --help` must stay the contacts help rather than quietly becoming the program's. And a column headed AGE held `2026-09-08T04:18:25-04:00` under --absolute. Every other time column already names the fact — HEARD, RECORDED, FINISHED — which stays true whichever form the run asked for; only chat's named the form. `when()` now says so where the header is declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
481 lines
20 KiB
Python
481 lines
20 KiB
Python
"""The shared shapes a report is built out of — one constructor per concept.
|
|
|
|
A node means the same thing in ``contacts``, ``monitor``, ``courier list``, ``trace`` and
|
|
``records``, and it should *look* the same in all five and parse the same in all five. The
|
|
way to guarantee that is not review, it is construction: every listing that mentions a
|
|
node asks this module for the column, so there is exactly one definition of what a node's
|
|
plain lanes are called and what its machine object holds.
|
|
|
|
Each constructor returns one :class:`~meshterm.ui.report.Column`, which carries both
|
|
projections of one typed value. The typed value is what a row holds — a :class:`NodeRef`,
|
|
a :class:`Position`, a :class:`~datetime.datetime`, a bare float — never a formatted cell,
|
|
so the plain face can print ``+5.1`` and the machine face ``5.1`` without either one
|
|
parsing the other's output back.
|
|
|
|
The interesting one is :func:`node`, and it is what earns the design: **one machine key
|
|
expands to as many plain columns as the surface has room for.** ``contacts`` spends four
|
|
(a name, a role, a hash, a key), ``monitor``'s live stream spends two, a trace's per-hop
|
|
table spends one — and all four documents carry the same five-key object, because the
|
|
lanes are a rendering choice and the shape is not.
|
|
|
|
Two absences, kept apart on purpose. ``None`` is *we do not know*, and it reads as
|
|
:data:`~meshterm.ui.script.NONE` plain and ``null`` in JSON. A word standing in for an
|
|
absence is not the same thing: ``unknown`` in a ``TYPE`` column is a word a reader
|
|
understands, so the plain face keeps it, while the document writes ``null`` — because a
|
|
consumer already has one spelling for "there is nothing here" and a second one costs it a
|
|
special case.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from . import script
|
|
from .report import Column, Lane, normalise
|
|
|
|
# -- the shared value shapes -----------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class NodeRef:
|
|
"""A mesh participant's identity, and nothing else.
|
|
|
|
The most repeated concept in either face. *Identity only*: when a node was heard, how
|
|
strong it came in, where it says it is — those belong to the **row**, because they
|
|
differ per surface and a node object that carried them would mean something different
|
|
in every one.
|
|
|
|
Attributes:
|
|
name: The name the node advertises, or ``None`` where none was ever heard —
|
|
including where a surface substitutes a bare hash for one.
|
|
key: The full public key, lowercase hex, **never truncated**: a key cut short is
|
|
not something a caller can hand back to ``--to`` or ``--path``. ``None`` where
|
|
only a hash was ever heard.
|
|
hash: The short derived id, at the width the surrounding surface addressed the
|
|
node by — never ``key`` shortened by this module's own choosing.
|
|
type: The advertised role in the lexicon's words (``node``, ``repeater``,
|
|
``room``, ``sensor``), or ``None`` where the advert never said.
|
|
is_self: Whether this is our own node. The plain face draws it like any other hop,
|
|
which is the point: "you already know who this is" is true of the person at
|
|
the prompt and false of whoever reads the file afterwards. The machine face
|
|
carries it as ``self``, because a parser has no way to work it out at all.
|
|
"""
|
|
|
|
name: str | None = None
|
|
key: str | None = None
|
|
hash: str | None = None
|
|
type: str | None = None
|
|
is_self: bool = False
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Position:
|
|
"""A shared location, in decimal degrees.
|
|
|
|
Unrounded in the document, as stored: the plain face's five decimals are a column-width
|
|
concession and a consumer should not inherit one.
|
|
"""
|
|
|
|
lat: float
|
|
lon: float
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ChannelRef:
|
|
"""One channel slot's identity. The secret is deliberately not in it.
|
|
|
|
A channel object travels through listings and send receipts, and a key that rode along
|
|
in all of them would be a key printed by commands nobody asked to print one. It
|
|
appears only where the caller explicitly asked (``channels share``), under its own
|
|
field, beside this.
|
|
|
|
Attributes:
|
|
slot: The 0-based index every ``channels`` subcommand's ``INDEX`` takes.
|
|
name: The channel name.
|
|
public: Whether the key is derived from the name (a ``#`` channel or a firmware
|
|
default) rather than secret.
|
|
hash: The channel hash, lowercase hex.
|
|
"""
|
|
|
|
slot: int
|
|
name: str
|
|
public: bool
|
|
hash: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Rendered:
|
|
"""A typed value beside the one plain rendering only its own registry can produce.
|
|
|
|
Almost every cell is derivable from its value alone, which is what lets a
|
|
:class:`~meshterm.ui.report.Lane` be a function of one argument. A *setting* is the
|
|
exception: what ``config set`` takes back for a given key is a question only that key's
|
|
spec can answer — an enum wants its number, an empty string wants ``""``, a preference
|
|
drops the unit its page shows — and the spec is per row, not per column.
|
|
|
|
So the row carries both halves, computed once where the spec is in hand. The plain
|
|
face prints ``text`` and the document emits ``value``, and neither has to parse the
|
|
other's output to get at what it needs, which is the same rule the rest of this module
|
|
keeps by other means.
|
|
|
|
Attributes:
|
|
value: The typed value, or ``None`` where the source never reported one.
|
|
text: What the plain face prints, in the form the matching ``set`` accepts back.
|
|
"""
|
|
|
|
value: Any
|
|
text: str
|
|
|
|
|
|
def node_json(ref: NodeRef | None) -> dict[str, Any] | None:
|
|
"""One node as its machine object, or ``null`` where there is no node at all."""
|
|
if ref is None:
|
|
return None
|
|
return {
|
|
"name": ref.name or None,
|
|
"key": (ref.key or None) and ref.key.lower(),
|
|
"hash": (ref.hash or None) and ref.hash.lower(),
|
|
"type": ref.type or None,
|
|
"self": ref.is_self,
|
|
}
|
|
|
|
|
|
def position_json(where: Position | None) -> dict[str, Any] | None:
|
|
"""One position as its machine object, or ``null`` where none was ever shared.
|
|
|
|
The whole object is ``null`` rather than a pair of ``null``s: a latitude without a
|
|
longitude is not half a location, it is no location.
|
|
"""
|
|
return None if where is None else {"lat": where.lat, "lon": where.lon}
|
|
|
|
|
|
def channel_json(channel: ChannelRef | None) -> dict[str, Any] | None:
|
|
"""One channel as its machine object, or ``null`` for an empty slot."""
|
|
if channel is None:
|
|
return None
|
|
return {
|
|
"slot": channel.slot,
|
|
"name": channel.name,
|
|
"public": channel.public,
|
|
"hash": (channel.hash or None) and channel.hash.lower(),
|
|
}
|
|
|
|
|
|
# -- the constructors ------------------------------------------------------------------
|
|
|
|
#: How each of a node's plain lanes reads its cell. A surface picks the ones it has room
|
|
#: for; the machine object is the same five keys either way.
|
|
_NODE_LANES = {
|
|
"name": lambda ref: script.name(ref.name if ref else None),
|
|
# The word, not a glyph: `▲` without its colour would need a legend and the CLI has no
|
|
# legends. `unknown` is a word a reader acts on; the document writes `null`.
|
|
"type": lambda ref: ref.type if ref and ref.type else "unknown",
|
|
"hash": lambda ref: ((ref.hash or "").lower() if ref else "") or script.NONE,
|
|
"key": lambda ref: ((ref.key or "").lower() if ref else "") or script.NONE,
|
|
}
|
|
|
|
|
|
def node(key: str = "node", *, lanes: Sequence[tuple[str, str]] = (("name", "NAME"),)) -> Column:
|
|
"""One node: the shared five-key object, and whichever plain lanes fit.
|
|
|
|
Args:
|
|
key: The machine key, and the key the row holds its :class:`NodeRef` at.
|
|
lanes: ``(part, header)`` pairs naming the plain columns to draw, in order. The
|
|
parts are ``name``, ``type``, ``hash`` and ``key``. A surface with one column
|
|
to spend draws the name; a per-hop table draws the hash, because that is what
|
|
joins it to the route line above it.
|
|
|
|
Returns:
|
|
The column.
|
|
"""
|
|
return Column(
|
|
key=key,
|
|
lanes=tuple(Lane(header=header, render=_NODE_LANES[part]) for part, header in lanes),
|
|
json=node_json,
|
|
)
|
|
|
|
|
|
def position(key: str = "position", header: str = "LOCATION") -> Column:
|
|
"""A shared location: one plain column, one machine object.
|
|
|
|
One column and not two. A coordinate pair is one fact to a reader, it is what goes
|
|
into a map's search box verbatim, and two columns of ``-`` for every node that has
|
|
never shared a position is worse than one.
|
|
"""
|
|
return Column(
|
|
key=key,
|
|
lanes=(Lane(header=header, render=_position_cell),),
|
|
json=position_json,
|
|
)
|
|
|
|
|
|
def channel(
|
|
key: str = "channel",
|
|
*,
|
|
lanes: Sequence[tuple[str, str]] = (("name", "NAME"),),
|
|
) -> Column:
|
|
"""One channel slot: the shared four-key object, and whichever plain lanes fit.
|
|
|
|
Args:
|
|
key: The machine key.
|
|
lanes: ``(part, header)`` pairs; the parts are ``slot``, ``name``, ``type`` and
|
|
``hash``. ``type`` is the plain face's word for the ``public`` boolean —
|
|
``public``/``private`` reads where ``true``/``false`` would need the heading
|
|
to say which way round it runs.
|
|
"""
|
|
parts = {
|
|
"slot": lambda c: script.number(c.slot if c else None),
|
|
"name": lambda c: script.name(c.name if c else None),
|
|
"type": lambda c: ("public" if c.public else "private") if c else script.NONE,
|
|
"hash": lambda c: ((c.hash or "").lower() if c else "") or script.NONE,
|
|
}
|
|
return Column(
|
|
key=key,
|
|
lanes=tuple(Lane(header=header, render=parts[part]) for part, header in lanes),
|
|
json=channel_json,
|
|
)
|
|
|
|
|
|
def when(key: str, header: str, *, absent: str = script.NONE) -> Column:
|
|
"""A time the reader reads as an age: ``5m``, ``3h``, ``never``.
|
|
|
|
The default form for every time in a listing, because "recently?" is the question the
|
|
reader typed the command to ask and an ISO instant makes them do arithmetic to answer
|
|
it. ``--absolute`` swaps the whole language back for a run; the document is UTC
|
|
regardless, since it is read somewhere else and often later.
|
|
|
|
Args:
|
|
key: The machine key. It ends in ``_at`` by convention — a JSON key is a type hint
|
|
for a parser, and the suffix is the hint.
|
|
header: The plain heading. It must name the *fact* — ``HEARD``, ``RECORDED`` —
|
|
rather than the form, because ``--absolute`` changes the form underneath it
|
|
and a column headed ``AGE`` holding an ISO instant is a heading that lies.
|
|
absent: What an unknown time reads as plain. ``never`` where the row exists and
|
|
the event has not happened (a node not yet heard, a conversation with nothing
|
|
said in it); the default token where the row has no such time at all.
|
|
|
|
Returns:
|
|
The column.
|
|
"""
|
|
return Column(
|
|
key=key,
|
|
lanes=(Lane(header=header, render=lambda v: _time(v, absent=absent), align="right"),),
|
|
)
|
|
|
|
|
|
def instant(key: str, header: str) -> Column:
|
|
"""A time that *is* the fact, printed absolutely whatever ``--absolute`` says.
|
|
|
|
Three surfaces want this and each for the same reason — the instant is the answer, not
|
|
a way of saying how long ago something was. The device's own clock. An appointment set
|
|
with ``--at``, which is a wall-clock time the caller chose. And a live capture's own
|
|
``TIME`` column, every row of which would otherwise read ``now``.
|
|
"""
|
|
return Column(key=key, lanes=(Lane(header=header, render=script.stamp),))
|
|
|
|
|
|
def snr(key: str, header: str) -> Column:
|
|
"""A signal-to-noise reading: ``+5.1`` plain, ``5.1`` in the document.
|
|
|
|
The plain sign is a column convention — a lane of readings only reads as a ladder when
|
|
every one of them declares its direction. A number in JSON carries its own sign and a
|
|
leading ``+`` would make it a string.
|
|
"""
|
|
return Column(
|
|
key=key,
|
|
lanes=(Lane(header=header, render=lambda v: script.number(v, "+.1f"), align="right"),),
|
|
json=lambda v: None if v is None else round(float(v), 1),
|
|
)
|
|
|
|
|
|
def integer(key: str, header: str) -> Column:
|
|
"""A whole number, right-aligned so a column of them is comparable by eye."""
|
|
return Column(key=key, lanes=(Lane(header=header, render=script.number, align="right"),))
|
|
|
|
|
|
def decimal(key: str, header: str, spec: str = ".1f") -> Column:
|
|
"""A measured number, formatted for the column and left unrounded in the document."""
|
|
return Column(
|
|
key=key,
|
|
lanes=(Lane(header=header, render=lambda v: script.number(v, spec), align="right"),),
|
|
)
|
|
|
|
|
|
def flag(key: str, header: str) -> Column:
|
|
"""A boolean: ``yes``/``no`` for a reader, ``true``/``false`` for a parser.
|
|
|
|
``None`` is a third answer and stays one — ``acked`` on a channel message is not
|
|
``false``, it is *there is no acknowledgement on a channel*, and the two are different
|
|
things to anyone counting delivery failures.
|
|
"""
|
|
return Column(
|
|
key=key,
|
|
lanes=(Lane(header=header, render=_flag_cell),),
|
|
)
|
|
|
|
|
|
def word(key: str, header: str) -> Column:
|
|
"""A member of a closed set — a state, a role, an outcome — as its own lowercase word.
|
|
|
|
Both faces say the same word, so ``courier send``'s ``no ack`` reads the same in a
|
|
document as it does on the line the help lists it on.
|
|
"""
|
|
return Column(key=key, lanes=(Lane(header=header, render=lambda v: v or script.NONE),))
|
|
|
|
|
|
def name(key: str, header: str) -> Column:
|
|
"""A bare name that is not a node's — a channel label, a conversation, a device."""
|
|
return Column(key=key, lanes=(Lane(header=header, render=script.name),))
|
|
|
|
|
|
def free(key: str, header: str) -> Column:
|
|
"""Free text a stranger filled in: escaped so it can never end its own record.
|
|
|
|
Always the last lane in a listing, because it is the one field with no width. The
|
|
document carries it **raw and unshortened** — a JSON string holds a newline natively,
|
|
so the escaping is a plain-text necessity rather than something to inflict on a
|
|
consumer.
|
|
"""
|
|
return Column(key=key, lanes=(Lane(header=header, render=script.text),))
|
|
|
|
|
|
def route(key: str = "route", header: str = "ROUTE") -> Column:
|
|
"""A walked hop sequence: drawn with arrows plain, an array of nodes in the document.
|
|
|
|
The row holds :class:`NodeRef` objects in propagation order, so the document's hops are
|
|
the same objects a listing's rows carry and a consumer's node handler is written once.
|
|
``None`` where nothing came home — never ``[]``, which would claim a walk of no hops.
|
|
"""
|
|
return Column(
|
|
key=key,
|
|
lanes=(
|
|
Lane(
|
|
header=header,
|
|
render=lambda hops: (
|
|
script.route([(h.name, h.hash) for h in hops]) if hops else script.NONE
|
|
),
|
|
),
|
|
),
|
|
json=lambda hops: None if hops is None else [node_json(hop) for hop in hops],
|
|
)
|
|
|
|
|
|
def spec(key: str = "path", header: str = "path") -> Column:
|
|
"""A forced path exactly as the radio was given it: ``a1,d4,a1``, or absent.
|
|
|
|
The one line on either face that round-trips into ``--path``, so it keeps its commas
|
|
and grows nothing. ``None`` means the device routed the walk itself: the plain face
|
|
says ``auto`` because a reader needs a word there, and the document says ``null``
|
|
because ``auto`` is not a path spec and R3 already has a token for "there is no spec
|
|
here".
|
|
"""
|
|
return Column(
|
|
key=key,
|
|
lanes=(Lane(header=header, render=lambda v: v if v else "auto"),),
|
|
)
|
|
|
|
|
|
def rendered(key: str, header: str) -> Column:
|
|
"""A setting's value: what ``set`` takes back plain, and the typed value in the document.
|
|
|
|
Takes a :class:`Rendered` (see there for why this one concept needs both halves in the
|
|
row). The round-trip claim is the plain face's whole design for these commands —
|
|
``config show > f`` and feeding ``f`` back in must work — and the document's typed
|
|
value is the machine face's largest single gain over it: ``9`` and not ``"9"``.
|
|
"""
|
|
return Column(
|
|
key=key,
|
|
lanes=(Lane(header=header, render=_rendered_cell),),
|
|
json=lambda r: None if r is None else r.value,
|
|
)
|
|
|
|
|
|
def hexid(key: str, header: str) -> Column:
|
|
"""A hash or a key on its own, lowercase, never truncated and never elided."""
|
|
return Column(
|
|
key=key,
|
|
lanes=(Lane(header=header, render=lambda v: (v or "").lower() or script.NONE),),
|
|
json=lambda v: (v or None) and str(v).lower(),
|
|
)
|
|
|
|
|
|
def note(key: str, header: str) -> Column:
|
|
"""Prose for a reader, with no machine face at all.
|
|
|
|
A preference's DESCRIPTION is the case: it explains a preference to somebody deciding
|
|
whether to change it, and there is nothing in it a program would branch on. Declaring
|
|
it as a column rather than smuggling it in keeps the listing's two faces stated in one
|
|
place.
|
|
"""
|
|
return Column(key=key, lanes=(Lane(header=header, render=script.text),), json=None)
|
|
|
|
|
|
def plain_only(column: Column) -> Column:
|
|
"""The same concept, drawn for a reader and left out of the document.
|
|
|
|
For a lane the machine face says *better* somewhere else rather than not at all. A
|
|
transcript's ``PEER`` is the case: one column holding whichever of a channel or a
|
|
contact the conversation had, because a reader has the conversation in front of them —
|
|
where the document keeps ``channel`` and ``node`` apart, since a parser has to know
|
|
which of the two it is holding and cannot tell from the label.
|
|
"""
|
|
from dataclasses import replace
|
|
|
|
return replace(column, json=None)
|
|
|
|
|
|
def hidden(key: str) -> Column:
|
|
"""A fact the document carries and the plain face has no room for.
|
|
|
|
The mirror of :func:`note`. ``config show`` prints a setting's key and its value —
|
|
that is what a reader came for — while the document also says what type it is, what an
|
|
enum's number is called, and whether the value was withheld.
|
|
"""
|
|
return Column(key=key)
|
|
|
|
|
|
def _rendered_cell(value: Rendered | None) -> str:
|
|
"""One setting's cell: the text its own spec produced, or the absent token."""
|
|
return script.NONE if value is None else value.text
|
|
|
|
|
|
def _position_cell(where: Position | None) -> str:
|
|
"""One position's cell: the pair, or the absent token."""
|
|
return script.NONE if where is None else script.location(where.lat, where.lon)
|
|
|
|
|
|
def _flag_cell(value: bool | None) -> str:
|
|
"""One boolean's cell, keeping ``None`` as an absence rather than a ``no``."""
|
|
return script.NONE if value is None else ("yes" if value else "no")
|
|
|
|
|
|
def _time(value: Any, *, absent: str) -> str:
|
|
"""Render a time in whichever language this run speaks (see :func:`when`).
|
|
|
|
``absent`` survives the switch to timestamps, because it is not a rendering choice:
|
|
``never`` says the event has not happened, and ``--absolute`` was asked for a different
|
|
*form* of time, not for one fewer fact.
|
|
"""
|
|
if not isinstance(value, datetime) and value is not None:
|
|
return normalise(value) # pragma: no cover - a row holding something else is a bug
|
|
if value is None:
|
|
return absent
|
|
return script.stamp(value) if _absolute() else script.age(value, absent=absent)
|
|
|
|
|
|
def _absolute() -> bool:
|
|
"""Whether this run prints absolute times.
|
|
|
|
Read from the preference registry rather than a module flag, because *how MeshTerm
|
|
behaves* is a preference by this project's own rule — and ``--absolute`` is then
|
|
simply that preference overridden for one run (see
|
|
:func:`~meshterm.cli.main_callback`), rather than a second switch saying the same
|
|
thing in a place a reader cannot find.
|
|
"""
|
|
from ..core.preferences import current
|
|
|
|
return str(current().cli_time_format) == "absolute"
|